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,17 +124,57 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
# отвечала «Please sign in to view available models» — при шести рабочих # отвечала «Please sign in to view available models» — при шести рабочих
# OAuth-профилях. Список моделей поэтому был пуст всегда. # OAuth-профилях. Список моделей поэтому был пуст всегда.
target_profile_id = profile_id target_profile_id = profile_id
if not target_profile_id: if target_profile_id:
try: try:
from antigravity_provider.router.profile_manager import ProfileAuthManager 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") main_p = ProfileAuthManager.get_main_profile("antigravity")
if main_p and ProfileAuthManager.load_profile_auth("antigravity", main_p): if main_p and main_p not in candidate_pids:
target_profile_id = main_p candidate_pids.append(main_p)
else:
for candidate in ["ag-orch-primary", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-w5"]: standard_slots = (
if ProfileAuthManager.load_profile_auth("antigravity", candidate): ["ag-orch-primary", "ag-orch-fallback"]
target_profile_id = candidate + [f"ag-{i}" for i in range(1, 21)]
break + [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: except Exception:
target_profile_id = None target_profile_id = None

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}' не найден"} return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
status = ProfileAuthManager.get_profile_status(pcfg.provider, 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': if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'} 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 candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
if not candidates: if not candidates:
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'} return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
@ -753,6 +753,8 @@ class ActionExecutor:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'} return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {} 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 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-ключ или не завершена авторизация'} return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
validation = None validation = None
if token or prov_norm in ('local', 'vllm', 'ollama'): if token or prov_norm in ('local', 'vllm', 'ollama'):
@ -790,7 +792,7 @@ class ActionExecutor:
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'} return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
elif prov_norm in ('claude', 'anthropic'): elif prov_norm in ('claude', 'anthropic'):
if not token: if not token:
return {'ok': False, 'message': 'Не указан API-ключ для Claude'} return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
elif prov_norm in ('opencode-go', 'opencode'): elif prov_norm in ('opencode-go', 'opencode'):
if not token: if not token:
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'} return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
@ -802,7 +804,7 @@ class ActionExecutor:
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'} return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
elif prov_norm in ('antigravity', 'google-antigravity'): elif prov_norm in ('antigravity', 'google-antigravity'):
if not token and not is_authenticated: if not token and not is_authenticated:
return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'} return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
else: else:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'} 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") 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"] DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
@ -54,21 +54,24 @@ class OllamaAdapter(LocalLLMAdapter):
def _get_native_host(self, base_url: str) -> str: def _get_native_host(self, base_url: str) -> str:
"""Strip trailing /v1 from base_url to get native Ollama host.""" """Strip trailing /v1 from base_url to get native Ollama host."""
if base_url.endswith("/v1"): b = base_url.rstrip("/")
return base_url[:-3] if b.endswith("/v1"):
return base_url return b[:-3].rstrip("/")
return b
def _get_chat_url(self, base_url: str) -> str: def _get_chat_url(self, base_url: str) -> str:
"""Get standard chat completions endpoint URL.""" """Get standard chat completions endpoint URL."""
if base_url.endswith("/v1"): b = base_url.rstrip("/")
return f"{base_url}/chat/completions" if b.endswith("/v1"):
return f"{base_url}/v1/chat/completions" return f"{b}/chat/completions"
return f"{b}/v1/chat/completions"
def _get_models_url(self, base_url: str) -> str: def _get_models_url(self, base_url: str) -> str:
"""Get OpenAI-compatible models endpoint URL.""" """Get OpenAI-compatible models endpoint URL."""
if base_url.endswith("/v1"): b = base_url.rstrip("/")
return f"{base_url}/models" if b.endswith("/v1"):
return f"{base_url}/v1/models" return f"{b}/models"
return f"{b}/v1/models"
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile) 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 "Сервер отвечает; моделей пока нет" message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}} return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
except Exception as exc: 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): if isinstance(exc, urllib.error.HTTPError):
reason = exc.reason or 'провайдер отклонил запрос' reason = exc.reason or 'провайдер отклонил запрос'
try: try:

View file

@ -369,9 +369,56 @@ class ModelDiscoveryService:
if prov in ("antigravity", "google-antigravity"): if prov in ("antigravity", "google-antigravity"):
from antigravity_provider.agy_subprocess import discover_models 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: 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: if res:
return sorted(list(set(res.values()))), None return sorted(list(set(res.values()))), None
return None, "Модели Google Antigravity не обнаружены" return None, "Модели Google Antigravity не обнаружены"

View file

@ -423,6 +423,17 @@ class ProfileAuthManager:
except Exception as e: except Exception as e:
logger.warning("Error reading %s: %s", auth_file, 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 # Fallbacks for specific providers
if provider == "openai-codex": if provider == "openai-codex":
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}" env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
@ -655,11 +666,24 @@ class ProfileAuthManager:
} }
if provider in ("antigravity", "google-antigravity"): if provider in ("antigravity", "google-antigravity"):
tokens = auth_data.get("token") or auth_data.get("tokens", {}) 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 "") if not isinstance(tokens, dict):
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "") tokens = {}
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "") acc_token = tokens.get("access_token") or auth_data.get("access_token") or ""
email = auth_data.get("email") 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 acc_id = None
if id_token: if id_token:
email_from_jwt, acc_id = cls.extract_jwt_identity(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_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
email = email or email_from_jwt email = email or email_from_jwt
expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date") expiry = (
if not expiry and isinstance(tokens, dict): tokens.get("expiry_date")
expiry = tokens.get("expires_at") or auth_data.get("expiry_date")
or tokens.get("expires_at")
or auth_data.get("expires_at")
)
if not expiry: 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: if expiry_str:
try: try:
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00")) dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
@ -688,7 +715,7 @@ class ProfileAuthManager:
is_expired = not bool(refresh_tok) is_expired = not bool(refresh_tok)
return { return {
"authenticated": True, "authenticated": not is_expired,
"provider": provider, "provider": provider,
"profile_id": profile_id, "profile_id": profile_id,
"email_masked": mask_email(email) if email else None, "email_masked": mask_email(email) if email else None,

View file

@ -11,6 +11,8 @@ import time
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider.version import __version__
from antigravity_provider.router.event_bus import ( from antigravity_provider.router.event_bus import (
EventBus, EventBus,
EVENT_ACCOUNT_UPDATED, EVENT_ACCOUNT_UPDATED,
@ -213,7 +215,9 @@ class HubStateStore:
hermes_cfg = {"exists": False, "model": None, "provider": None} hermes_cfg = {"exists": False, "model": None, "provider": None}
default_role = "manager" default_role = "manager"
from antigravity_provider.version import __version__
metrics = { metrics = {
"version": __version__,
"generation": gen, "generation": gen,
"seq": request_seq, "seq": request_seq,
"duration_ms": round((time.time() - t0) * 1000, 2), "duration_ms": round((time.time() - t0) * 1000, 2),
@ -311,6 +315,7 @@ class HubStateStore:
routing={}, routing={},
quotas={}, quotas={},
metrics={ metrics={
"version": __version__,
"generation": 0, "generation": 0,
"seq": 0, "seq": 0,
"telemetry": telemetry_data, "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)) return JSONResponse(content=jsonable_encoder(snap_dict))
@app.post("/api/action") @app.post("/api/action")
@ -387,15 +394,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
last_check = UpdateManager.get_last_check_result() last_check = UpdateManager.get_last_check_result()
server_host = raw.get("web_api_host", "127.0.0.1") server_host = raw.get("web_api_host", "127.0.0.1")
is_external = (server_host != "127.0.0.1" and server_host != "localhost") 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] = { settings_out: Dict[str, Any] = {
"system_paths": system_paths,
"web_api_host": server_host, "web_api_host": server_host,
"web_api_port": raw.get("web_api_port", 5800), "web_api_port": raw.get("web_api_port", 5800),
"web_api_token_configured": has_token, "web_api_token_configured": has_token,
"theme": raw.get("theme", "system"), "theme": raw.get("theme", "system"),
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300), "quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", raw.get("account_check_interval_seconds", 300)),
"hermes_home": str(paths.get_hermes_home()), "hermes_home": system_paths["hermes_home"],
"config_dir": str(paths.get_config_dir()), "config_dir": system_paths["config_dir"],
"log_file": str(paths.get_log_file()), "log_file": system_paths["log_file"],
"installed_commit": get_installed_commit(), "installed_commit": get_installed_commit(),
"version": __version__, "version": __version__,
"last_update_check": last_check.to_dict() if last_check else None, "last_update_check": last_check.to_dict() if last_check else None,

View file

@ -71,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
initNavigation(); initNavigation();
initEventListeners(); initEventListeners();
initSettings(); initSettings();
fetchSettings();
fetchSnapshot(); fetchSnapshot();
startPolling(); startPolling();
checkUpdates(true); checkUpdates(true);
@ -114,6 +115,10 @@ function switchView(viewName) {
fetchSkills(); fetchSkills();
} }
if (viewName === 'settings') {
fetchSettings();
}
if (currentSnapshot) { if (currentSnapshot) {
renderCurrentView(); renderCurrentView();
} }
@ -620,6 +625,12 @@ function updateGlobalHeader() {
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length; 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 ── // ── VIEW ROUTER ──
@ -1616,86 +1627,166 @@ function renderLogsList(events) {
} }
// ── SETTINGS MANAGEMENT ── // ── 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() { function renderSettingsView() {
if (!currentSnapshot) return; const s = currentSettings || {};
const paths = {}; // HubSnapshot does not expose filesystem paths. const sysPaths = s.system_paths || (currentSnapshot && currentSnapshot.system_paths) || {};
const s = currentSettings;
const elHome = document.getElementById('path-hermes-home'); const elHome = document.getElementById('path-hermes-home');
const elConfig = document.getElementById('path-config-dir'); const elConfig = document.getElementById('path-config-dir');
const elLog = document.getElementById('path-log-file'); const elLog = document.getElementById('path-log-file');
if (elHome) elHome.textContent = paths.hermes_home || 'Н/Д: API не передаёт путь'; const homePath = sysPaths.hermes_home || s.hermes_home;
if (elConfig) elConfig.textContent = paths.config_dir || 'Н/Д: API не передаёт путь'; const configPath = sysPaths.config_dir || s.config_dir;
if (elLog) elLog.textContent = paths.log_file || 'Н/Д: API не передаёт путь'; const logPath = sysPaths.log_file || s.log_file;
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); if (elHome) elHome.textContent = homePath || 'Н/Д: путь не передан сервером';
const quotaActionSel = document.getElementById('setting-quota-threshold-action'); if (elConfig) elConfig.textContent = configPath || 'Н/Д: путь не передан сервером';
const emailMaskingSel = document.getElementById('setting-email-masking-mode'); if (elLog) elLog.textContent = logPath || 'Н/Д: путь не передан сервером';
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
// 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 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) { if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); 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) { if (quotaActionSel && s.quota_threshold_action) {
quotaActionSel.value = 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) { if (emailMaskingSel && s.email_masking_mode) {
emailMaskingSel.value = s.email_masking_mode; emailMaskingSel.value = s.email_masking_mode;
} }
if (accountIntervalInput && !accountIntervalInput.dataset.loaded) {
accountIntervalInput.dataset.loaded = 'loading'; // Default Role
fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}}) const defaultRoleSel = document.getElementById('setting-default-role');
.then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); }) if (defaultRoleSel) {
.then(settings => { const currentDef = s.default_role || (currentSnapshot && currentSnapshot.metrics && currentSnapshot.metrics.default_role) || 'manager';
if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером'); defaultRoleSel.value = currentDef;
accountIntervalInput.value = settings.account_check_interval_seconds;
accountIntervalInput.disabled = false;
accountIntervalInput.dataset.loaded = 'yes';
}).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; });
} }
// 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) { if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
monitorIntervalInput.value = s.monitoring_interval_seconds; monitorIntervalInput.value = s.monitoring_interval_seconds;
} }
// Obsidian Vault Path
const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
if (vaultPathInput) { if (vaultPathInput) {
vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory'; 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() { 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 quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode'); const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const quotaIntervalSel = document.getElementById('setting-quota-interval');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
const accountIntervalInput = document.getElementById('setting-account-check-interval'); const accountIntervalInput = document.getElementById('setting-account-check-interval');
const defaultRoleSel = document.getElementById('setting-default-role'); const defaultRoleSel = document.getElementById('setting-default-role');
const themeSel = document.getElementById('setting-theme');
const newSettings = {}; const newSettings = {};
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value)); if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim();
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value); if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value);
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value; if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim();
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value; if (accountIntervalInput && accountIntervalInput.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value); if (quotaIntervalSel && quotaIntervalSel.value) newSettings.quota_refresh_interval_sec = Number(quotaIntervalSel.value);
if (vaultPathInput?.value) newSettings.obsidian_vault_path = vaultPathInput.value.trim(); if (quotaThresholdSel && quotaThresholdSel.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
if (defaultRoleSel?.value) newSettings.default_role = defaultRoleSel.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; } if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
showToast('Сохранение настроек сервера...', 'info'); showToast('Сохранение настроек сервера...', 'info');
const res = await executeAction('save_settings', newSettings); const res = await executeAction('save_settings', newSettings);
if (res.ok) { if (res && res.ok) {
showToast('Настройки сервера успешно сохранены', 'success'); showToast('Настройки сервера успешно сохранены', 'success');
await fetchSettings();
fetchSnapshot(); fetchSnapshot();
} else { } else {
showToast(res.message || 'Ошибка сохранения настроек сервера', 'error'); showToast((res && res.message) || 'Ошибка сохранения настроек сервера', 'error');
} }
} }
@ -2826,7 +2917,7 @@ function showWizardStep2(providerId) {
</div> </div>
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label> <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 style="font-size:12px; color:var(--text-muted); margin-top:4px;">
Вход в занятый слот заменит учётные данные, которые в нём сейчас. Вход в занятый слот заменит учётные данные, которые в нём сейчас.
</div> </div>
@ -2835,7 +2926,18 @@ function showWizardStep2(providerId) {
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button> <button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
</div> </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 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> </div>
`; `;
footerHtml = ` footerHtml = `
@ -2850,10 +2952,14 @@ function showWizardStep2(providerId) {
</div> </div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label> <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>
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);"> <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>
<div style="margin-bottom:12px;"> <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> <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} ${bodyHtml}
`; `;
elements.modalFooter.innerHTML = footerHtml; 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) { 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, // 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. // antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements.
const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex'; 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) { if (isDeviceAuthFlow) {
const deviceSlot = document.getElementById('wiz-device-slot'); 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) { } 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'); const redirectSlot = document.getElementById('wiz-redirect-slot');
window._wiz_device_profile = redirectSlot?.value || ''; window._wiz_device_profile = redirectSlot?.value || '';
} }
@ -3055,6 +3200,8 @@ async function finishAddAccount(providerId) {
let selectedProfileId; let selectedProfileId;
if (isLocalProvider) { if (isLocalProvider) {
selectedProfileId = ''; selectedProfileId = '';
} else if (providerId === 'antigravity' || providerId === 'claude') {
selectedProfileId = window._wiz_redirect_slot_id || window._wiz_device_profile || document.getElementById('wiz-redirect-slot')?.value || '';
} else { } else {
const deviceSlot = document.getElementById('wiz-device-slot'); const deviceSlot = document.getElementById('wiz-device-slot');
const redirectSlot = document.getElementById('wiz-redirect-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_session = d.session_id;
window._wiz_redirect_provider = providerId; window._wiz_redirect_provider = providerId;
window._wiz_redirect_slot_id = d.profile_id; window._wiz_redirect_slot_id = d.profile_id;
window._wiz_device_profile = d.profile_id;
const pastesUrl = d.paste_kind !== 'code'; const pastesUrl = d.paste_kind !== 'code';
const label = pastesUrl const label = pastesUrl
@ -3363,6 +3511,7 @@ async function submitRedirectCallback() {
if (res && res.ok) { if (res && res.ok) {
stopRedirectAuthPolling(); stopRedirectAuthPolling();
window._wiz_device_profile = window._wiz_redirect_slot_id;
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + redirectSlotRoleNote(); status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + redirectSlotRoleNote();
showToast('Аккаунт подключён', 'success'); showToast('Аккаунт подключён', 'success');
fetchSnapshot(); fetchSnapshot();
@ -3405,6 +3554,7 @@ async function pollRedirectAuth() {
if (res.ok && (res.data || {}).status === 'completed') { if (res.ok && (res.data || {}).status === 'completed') {
stopRedirectAuthPolling(); stopRedirectAuthPolling();
window._wiz_device_profile = window._wiz_redirect_slot_id;
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>'; status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
showToast('Аккаунт подключён', 'success'); showToast('Аккаунт подключён', 'success');
fetchSnapshot(); fetchSnapshot();

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.1.2" __version__ = "0.1.2"
VERSION_INFO = (0, 1, 1) VERSION_INFO = (0, 1, 2)
CHANNEL = "stable" CHANNEL = "stable"
MINIMUM_HERMES_VERSION = "0.20.0" 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 # Missing API key for claude
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""}) res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
assert res_cl["ok"] is False 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 # Unsupported provider
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"}) 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" assert resp["choices"][0]["message"]["content"] == "pong"
req_arg = mock_urlopen.call_args[0][0] 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 req_arg.get_method() == "POST"
assert "Authorization" not in req_arg.headers assert "Authorization" not in req_arg.headers

View file

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