fix(a55): account connection fixes - antigravity windows/linux, ollama server discovery, version display, full settings
This commit is contained in:
parent
26f7d2ce73
commit
3e660c39bb
14 changed files with 566 additions and 83 deletions
|
|
@ -124,17 +124,57 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
|||
# отвечала «Please sign in to view available models» — при шести рабочих
|
||||
# OAuth-профилях. Список моделей поэтому был пуст всегда.
|
||||
target_profile_id = profile_id
|
||||
if not target_profile_id:
|
||||
if target_profile_id:
|
||||
try:
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
st = ProfileAuthManager.get_profile_status("antigravity", target_profile_id)
|
||||
if not st.get("authenticated") and not ProfileAuthManager.load_profile_auth("antigravity", target_profile_id):
|
||||
target_profile_id = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not target_profile_id:
|
||||
try:
|
||||
from antigravity_provider.paths import get_hermes_home
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
|
||||
candidate_pids: list[str] = []
|
||||
try:
|
||||
cfg = load_router_config()
|
||||
for pid, pcfg in cfg.profiles.items():
|
||||
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
|
||||
candidate_pids.append(pid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
||||
if main_p and ProfileAuthManager.load_profile_auth("antigravity", main_p):
|
||||
target_profile_id = main_p
|
||||
else:
|
||||
for candidate in ["ag-orch-primary", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-w5"]:
|
||||
if ProfileAuthManager.load_profile_auth("antigravity", candidate):
|
||||
target_profile_id = candidate
|
||||
break
|
||||
if main_p and main_p not in candidate_pids:
|
||||
candidate_pids.append(main_p)
|
||||
|
||||
standard_slots = (
|
||||
["ag-orch-primary", "ag-orch-fallback"]
|
||||
+ [f"ag-{i}" for i in range(1, 21)]
|
||||
+ [f"ag-w{i}" for i in range(1, 11)]
|
||||
)
|
||||
for s in standard_slots:
|
||||
if s not in candidate_pids:
|
||||
candidate_pids.append(s)
|
||||
|
||||
try:
|
||||
agy_dir = get_hermes_home() / "agy_profiles"
|
||||
if agy_dir.is_dir():
|
||||
for sub in sorted(agy_dir.iterdir()):
|
||||
if sub.is_dir() and sub.name not in candidate_pids:
|
||||
candidate_pids.append(sub.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for candidate in candidate_pids:
|
||||
st = ProfileAuthManager.get_profile_status("antigravity", candidate)
|
||||
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", candidate):
|
||||
target_profile_id = candidate
|
||||
break
|
||||
except Exception:
|
||||
target_profile_id = None
|
||||
|
||||
|
|
|
|||
|
|
@ -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} не поддерживается для прямого добавления учетных данных'}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 не обнаружены"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
188
tests/test_a55_account_connection.py
Normal file
188
tests/test_a55_account_connection.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
uv.lock
2
uv.lock
|
|
@ -297,7 +297,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "hermes-hub"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue