merge: A42, A45, A47, A48 — провайдеры, замеры MoE, общая память, интерфейс по макетам

A48 (Codex): вёрстка по макетам. style.css изменён на 178 строк, добавлен
workspace.js, приложены 79 скриншотов — до, после, макеты для сверки.
Проверено глазами: логотип, шапка с поиском, панель инструментов холста,
карточки узлов с моделью и аккаунтом, читаемые подписи связей, инспектор с
вкладками, три карточки внизу. Выдуманных чисел из макета нет, пустые
состояния честные.

A47: хранилище AI-Memory под git локально, память проекта приведена к
действительности, worklog заполнен, составлен перечень агентов сервера.

A45: три кандидата замерены. Файлы и размеры сверены по диску, контрольная
сумма Qwen3-Coder пересчитана независимо и совпала. Скорость 109,6 ток/с
на 64К независимо НЕ перемерена: свободно 1,9 ГБ видеопамяти, проверка
потребовала бы остановить рабочий кодер владельца.

A42: подключение OpenRouter, NVIDIA и Ollama, отдельные ветки обнаружения
моделей, отказ с причиной вместо мнимого успеха.

Конфликт A42 с A41 разрешён в пользу A41: ветка A42 отведена от e7194d3,
до слияния A41 в main, поэтому в ней не было динамических префиксов.
Сохранены _get_prefix и генерация слотов; зашитый provider_slots не взят.
Расширенный список ролей openrouter из A42 принят.

Ожидание теста A42 поправлено: nvidia и nvidia-nim — псевдонимы одного
провайдера с одним адаптером, слоты у них общие.

517 passed, ruff clean, релизный гейт 10/10 и на конфигурации владельца,
и на пустой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-31 17:04:56 +07:00
commit 17b368a155
9 changed files with 1106 additions and 140 deletions

View file

@ -650,27 +650,75 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
# Подключение аккаунта: для локального сервера/Ollama это не навигация, а
# настоящее сохранение профиля с адресом.
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
if action == 'add_account':
prov_norm = (prov or data.get('provider') or '').strip().lower()
if not prov_norm:
return {'ok': False, 'message': 'Провайдер не указан'}
target_role = data.get('target_role', 'coder-primary')
base_url = data.get('base_url', '')
token = data.get('token', '')
if prov in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm') and base_url:
slot = data.get('profile_id') or AutoAssigner.find_free_slot(prov) or f'{prov}-1'
AutoAssigner.ensure_profile_definition(prov, slot)
auth_data = {
"provider": prov,
"profile_id": slot,
"base_url": base_url,
"api_key": token if token else None,
"created_at": time.time(),
}
ProfileAuthManager.save_profile_auth(prov, slot, auth_data)
base_url = (data.get('base_url') or '').strip()
token = (data.get('token') or data.get('api_key') or '').strip()
slot = data.get('profile_id')
default_base_urls = {
'openrouter': 'https://openrouter.ai/api/v1',
'nvidia': 'https://integrate.api.nvidia.com/v1',
'nvidia-nim': 'https://integrate.api.nvidia.com/v1',
'ollama': 'http://127.0.0.1:11434',
'local': 'http://127.0.0.1:8081/v1',
'local-llm': 'http://127.0.0.1:8081/v1',
'llama.cpp': 'http://127.0.0.1:8081/v1',
'vllm': 'http://127.0.0.1:8081/v1',
}
if not base_url and prov_norm in default_base_urls:
base_url = default_base_urls[prov_norm]
# Validate required credentials per provider
if prov_norm in ('openrouter',):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для OpenRouter'}
elif prov_norm in ('nvidia', 'nvidia-nim'):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
elif prov_norm in ('claude', 'anthropic'):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
elif prov_norm in ('opencode-go', 'opencode'):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
elif prov_norm in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm'):
if not base_url:
return {'ok': False, 'message': f'Не указан URL сервера для {prov_norm}'}
elif prov_norm in ('openai-codex', 'codex', 'grok', 'xai'):
if not token:
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
else:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
if not ok:
return {'ok': False, 'message': def_msg}
auth_data: Dict[str, Any] = {
"provider": prov_norm,
"profile_id": slot,
"created_at": time.time(),
}
if base_url:
auth_data["base_url"] = base_url
if token:
auth_data["api_key"] = token
try:
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
return {'ok': True, 'message': f'Сервер {prov} ({slot}) успешно подключен'}
return {'ok': True, 'message': 'Навигация'}
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —

View file

@ -128,8 +128,23 @@ class CodexAdapter(BaseProviderAdapter):
err_msg = str(exc)
err_lower = err_msg.lower()
# Quota / usage limit
if any(k in err_lower for k in ("quota", "insufficient_quota", "usage_limit", "exceeded your current quota")):
# 1. Rate limited (429) - must take precedence over generic limit checks
if any(k in err_lower for k in ("429", "rate_limit", "rate limit", "tokens per min", "requests per min", "tpm", "rpm", "too many requests")):
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=60,
)
# 2. Auth errors
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid_api_key", "token_invalidated", "token_revoked", "invalid token")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
# 3. Real Quota exhausted
if any(k in err_lower for k in ("insufficient_quota", "exceeded your current quota", "quota_exceeded", "quota exceeded", "credit balance is too low", "billing", "out of credits", "run out of credits")):
reset_sec = 1800
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
if m_sec:
@ -140,23 +155,8 @@ class CodexAdapter(BaseProviderAdapter):
reset_duration_seconds=reset_sec,
)
# Rate limited (429)
if "429" in err_lower or "rate limit" in err_lower or "tokens per min" in err_lower:
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=60,
)
# Auth errors
if any(k in err_lower for k in ("401", "unauthorized", "invalid_api_key", "token_invalidated", "token_revoked")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
# Transient
if any(k in err_lower for k in ("timeout", "502", "503", "504", "connection reset")):
# 4. Transient network / server errors
if any(k in err_lower for k in ("timeout", "500", "502", "503", "504", "connection reset", "connection refused", "transport error")):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,

View file

@ -49,6 +49,12 @@ DEFAULT_SLOT_ROLES = {
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
"local-1": ("Локальный сервер 1", "coder", "primary"),
"local-2": ("Локальный сервер 2", "fast", "primary"),
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
"nvidia-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
"nvidia-nim-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
"nvidia-nim-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
"ag-spare-1": ("Резерв 1", "spare", "spare"),
"ag-spare-2": ("Резерв 2", "spare", "spare"),
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
@ -206,7 +212,7 @@ class AutoAssigner:
"llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"],
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
"vllm": ["reviewer", "coding", "reasoning", "fast", "research"],
"openrouter": ["coding", "reasoning", "research", "fast"],
"openrouter": ["coding", "reasoning", "research", "fast", "reviewer"],
"nvidia": ["coding", "reasoning", "fast"],
"nvidia-nim": ["coding", "reasoning", "fast"],
}
@ -543,6 +549,9 @@ class AutoAssigner:
"llama.cpp": "Local LLM (llama.cpp)",
"ollama": "Ollama",
"vllm": "vLLM",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"nvidia-nim": "NVIDIA NIM",
}
provider_label = prov_labels.get(pcfg.provider.lower(), pcfg.provider)

View file

@ -479,8 +479,6 @@ class HealthTracker:
record.last_used = now
record.last_error = reason
record.simulated = simulated
if not model_name or model_name == "default":
record.overall_state = QUOTA_EXHAUSTED
family = extract_model_family(model_name)
if family not in record.families:

View file

@ -16,7 +16,7 @@ import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger("hermes.router.model_discovery")
@ -93,18 +93,27 @@ class ModelDiscoveryService:
"discovered_at": None,
"is_stale": True,
"has_cache": False,
"error": entry.get("error") if entry else None,
}
discovered_at = entry.get("discovered_at", 0)
is_stale = (time.time() - discovered_at) > self._ttl_seconds
discovered_at = entry.get("discovered_at")
is_stale = (time.time() - float(discovered_at)) > self._ttl_seconds if discovered_at else True
models = entry.get("models")
return {
"provider": provider,
"models": list(entry["models"]),
"models": list(models) if models else None,
"discovered_at": discovered_at,
"is_stale": is_stale,
"has_cache": True,
"has_cache": bool(models),
"error": entry.get("error"),
}
def get_error(self, provider: str) -> Optional[str]:
"""Return last discovery error message for provider if any."""
with self._cache_lock:
entry = self._cache.get(provider.lower())
return entry.get("error") if entry else None
def get_cached(self, provider: str) -> Dict[str, Any]:
"""Convenience alias for get_models_with_metadata."""
return self.get_models_with_metadata(provider)
@ -144,9 +153,19 @@ class ModelDiscoveryService:
on_complete: Optional[Callable[[Dict[str, Optional[List[str]]]], None]] = None,
timeout: float = 15.0,
) -> None:
"""Discover models for all 5 providers concurrently in background."""
"""Discover models for all configured providers concurrently in background."""
def _worker():
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok", "local"]
providers = [
"antigravity",
"openai-codex",
"opencode-go",
"claude",
"grok",
"openrouter",
"nvidia",
"ollama",
"local",
]
results: Dict[str, Optional[List[str]]] = {}
threads = []
@ -172,13 +191,15 @@ class ModelDiscoveryService:
def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]:
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
result_holder: List[Optional[List[str]]] = [None]
error_holder: List[Optional[Exception]] = [None]
error_holder: List[Optional[str]] = [None]
def _do_probe():
try:
result_holder[0] = self._probe_provider(provider)
models, err_msg = self._probe_provider(provider)
result_holder[0] = models
error_holder[0] = err_msg
except Exception as exc:
error_holder[0] = exc
error_holder[0] = str(exc)
worker = threading.Thread(target=_do_probe, daemon=True)
worker.start()
@ -186,51 +207,139 @@ class ModelDiscoveryService:
if worker.is_alive():
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
# Timeout: retain existing cache if any
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
if error_holder[0]:
logger.info("Model discovery probe for '%s' returned error: %s", provider, error_holder[0])
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
entry = self._cache.get(provider.lower(), {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": timeout_msg,
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
models = result_holder[0]
err_text = error_holder[0]
if models:
with self._cache_lock:
self._cache[provider.lower()] = {
"models": models,
"discovered_at": time.time(),
"error": None,
}
self._save_cache_to_disk()
logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models)
return models
# If probe returned None or empty, retain existing cache if any
with self._cache_lock:
entry = self._cache.get(provider.lower())
return list(entry["models"]) if entry and "models" in entry else None
if err_text:
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
with self._cache_lock:
entry = self._cache.get(provider.lower(), {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": err_text,
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
def _probe_provider(self, provider: str) -> Optional[List[str]]:
"""Perform provider-specific model discovery."""
with self._cache_lock:
entry = self._cache.get(provider.lower(), {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": entry.get("error") or "Модели не найдены",
}
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
try:
raw_err = http_err.read().decode("utf-8", errors="replace")
err_json = json.loads(raw_err)
if isinstance(err_json, dict):
if "error" in err_json:
err_obj = err_json["error"]
if isinstance(err_obj, dict):
msg = err_obj.get("message") or str(err_obj)
else:
msg = str(err_obj)
elif "message" in err_json:
msg = str(err_json["message"])
elif "detail" in err_json:
msg = str(err_json["detail"])
else:
msg = raw_err
else:
msg = raw_err
return f"HTTP {http_err.code}: {msg}"
except Exception:
return f"HTTP {http_err.code}: {http_err.reason}"
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
from antigravity_provider.router.router_config import load_router_config
cfg = load_router_config()
p_lower = prov.lower()
matched = [
(pid, pcfg)
for pid, pcfg in cfg.profiles.items()
if pcfg.provider.lower() == p_lower
or (p_lower in ("nvidia", "nvidia-nim") and pcfg.provider.lower() in ("nvidia", "nvidia-nim"))
or (p_lower in ("openai-codex", "codex") and pcfg.provider.lower() in ("openai-codex", "codex"))
or (p_lower in ("opencode-go", "opencode") and pcfg.provider.lower() in ("opencode-go", "opencode"))
or (p_lower in ("claude", "anthropic") and pcfg.provider.lower() in ("claude", "anthropic"))
or (p_lower in ("grok", "xai") and pcfg.provider.lower() in ("grok", "xai"))
or (p_lower in ("local", "local-llm", "llama.cpp", "vllm") and pcfg.provider.lower() in ("local", "local-llm", "llama.cpp", "vllm"))
]
if matched:
return matched
default_slots = {
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
"opencode": ["opengo-1", "opengo-2", "opengo-3"],
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"openrouter": ["openrouter-1", "openrouter-2"],
"nvidia": ["nvidia-1", "nvidia-2"],
"nvidia-nim": ["nvidia-nim-1", "nvidia-nim-2"],
"ollama": ["ollama-1", "ollama-2"],
"local": ["local-1", "local-2"],
"local-llm": ["local-1", "local-2"],
"llama.cpp": ["local-1", "local-2"],
"vllm": ["local-1", "local-2"],
}
candidates = default_slots.get(p_lower, [f"{p_lower}-1", f"{p_lower}-2"])
return [(pid, cfg.get_profile(pid)) for pid in candidates]
def _probe_provider(self, provider: str) -> Tuple[Optional[List[str]], Optional[str]]:
"""Perform provider-specific model discovery returning (models_list, error_msg)."""
prov = provider.lower()
from antigravity_provider.router.profile_manager import ProfileAuthManager
if prov == "antigravity":
if prov in ("antigravity", "google-antigravity"):
from antigravity_provider.agy_subprocess import discover_models
main_p = ProfileAuthManager.get_main_profile("antigravity")
res = discover_models(profile_id=main_p)
if res:
return sorted(list(set(res.values())))
return None
main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
try:
res = discover_models(profile_id=main_p)
if res:
return sorted(list(set(res.values()))), None
return None, "Модели Google Antigravity не обнаружены"
except Exception as exc:
return None, str(exc)
elif prov in ("openai-codex", "codex"):
for pid in ["codex-orch", "codex-worker-1", "codex-worker-2"]:
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid)
if not auth:
continue
profiles = self._get_provider_candidate_profiles("openai-codex")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or auth
access_token = (
tokens.get("access_token")
@ -239,9 +348,13 @@ class ModelDiscoveryService:
)
if not access_token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.openai.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
req = urllib.request.Request(
"https://api.openai.com/v1/models",
f"{base_url}/models",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
@ -257,22 +370,31 @@ class ModelDiscoveryService:
m for m in models
if any(x in m for x in ("gpt-4", "gpt-3.5", "o1", "o3", "codex", "chatgpt"))
]
return sorted(chat_models or models)
if chat_models or models:
return sorted(chat_models or models), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Codex model query failed on %s: %s", pid, exc)
return None
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
elif prov in ("opencode-go", "opencode"):
for pid in ["opengo-1", "opengo-2", "opengo-3"]:
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid)
if not auth:
continue
profiles = self._get_provider_candidate_profiles("opencode-go")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid) or {}
api_key = auth.get("api_key")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://opencode.ai/zen/go/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
req = urllib.request.Request(
"https://opencode.ai/zen/go/v1/models",
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
@ -284,32 +406,36 @@ class ModelDiscoveryService:
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [str(m.get("id") or m) for m in items if m]
return sorted(models)
if models:
return sorted(models), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
elif prov == "grok":
# Провайдера здесь не было вовсе, поэтому кэш моделей Grok всегда
# оставался пустым, и выбор модели отвергал даже настоящие имена:
# «модель grok-4.5 не найдена в списке известных». При этом
# api.x.ai/v1/models принимает тот же OAuth-токен, что и вызовы, и
# отдаёт полный список.
for pid in ("grok-orch", "grok-worker-1", "grok-worker-2"):
auth = ProfileAuthManager.load_profile_auth("grok", pid)
if not auth:
continue
elif prov in ("grok", "xai"):
profiles = self._get_provider_candidate_profiles("grok")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("grok", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None
token = token or auth.get("access_token") or auth.get("api_key")
if not token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.x.ai/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
try:
request = urllib.request.Request(
"https://api.x.ai/v1/models",
headers={"Authorization": f"Bearer {token}"},
req = urllib.request.Request(
f"{base_url}/models",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "hermes-hub/1.0"},
)
with urllib.request.urlopen(request, timeout=15) as response:
with urllib.request.urlopen(req, timeout=15) as response:
payload = json.loads(response.read().decode("utf-8") or "{}")
models = [
str(item.get("id"))
@ -317,25 +443,215 @@ class ModelDiscoveryService:
if isinstance(item, dict) and item.get("id")
]
if models:
return sorted(set(models))
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None
return None, last_err or "Отсутствуют учетные данные для Grok"
elif prov in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
from antigravity_provider.router.router_config import load_router_config
cfg = load_router_config()
for pid in ["local-1", "local-2"]:
pcfg = cfg.get_profile(pid)
auth = ProfileAuthManager.load_profile_auth("local", pid) or {}
base_url = (
(pcfg.custom_base_url if pcfg else None)
or auth.get("base_url")
or os.environ.get("LOCAL_LLM_BASE_URL")
or "http://127.0.0.1:8081/v1"
)
if not base_url:
elif prov in ("claude", "anthropic"):
profiles = self._get_provider_candidate_profiles("claude")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("claude", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None
token = token or auth.get("access_token") or auth.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
if not token:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.anthropic.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
headers = {
"Accept": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-hub/1.0",
}
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = [str(item.get("id") or item) for item in items if item]
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
except Exception as exc:
last_err = str(exc)
return None, last_err or "Отсутствуют учетные данные для Claude"
elif prov in ("openrouter",):
profiles = self._get_provider_candidate_profiles("openrouter")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("openrouter", pid) or {}
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("OPENROUTER_API_KEY")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OPENROUTER_BASE_URL") or "https://openrouter.ai/api/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
referer = (
os.environ.get("OPENROUTER_HTTP_REFERER")
or os.environ.get("HERMES_REFERER")
or "https://github.com/ochenstarik-ui/hermes-hub"
)
title = (
os.environ.get("OPENROUTER_APP_TITLE")
or os.environ.get("OPENROUTER_TITLE")
or "Hermes Hub"
)
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": referer,
"X-OpenRouter-Title": title,
"X-Title": title,
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = []
if isinstance(items, list):
for item in items:
mid = item.get("id") if isinstance(item, dict) else str(item)
if mid:
models.append(str(mid))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
elif prov in ("nvidia", "nvidia-nim"):
profiles = self._get_provider_candidate_profiles("nvidia")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("nvidia", pid) or ProfileAuthManager.load_profile_auth("nvidia-nim", pid) or {}
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
if not api_key:
continue
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("NVIDIA_BASE_URL") or "https://integrate.api.nvidia.com/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
try:
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
payload = json.loads(resp.read().decode("utf-8") or "{}")
items = payload.get("data") or payload.get("models") or []
models = []
if isinstance(items, list):
for item in items:
mid = item.get("id") if isinstance(item, dict) else str(item)
if mid:
models.append(str(mid))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
elif prov == "ollama":
profiles = self._get_provider_candidate_profiles("ollama")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("ollama", pid) or {}
raw_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_HOST") or "http://127.0.0.1:11434"
raw_url = str(raw_url).strip().rstrip("/")
if not raw_url.startswith(("http://", "https://")):
raw_url = f"http://{raw_url}"
native_host = raw_url[:-3] if raw_url.endswith("/v1") else raw_url
v1_url = raw_url if raw_url.endswith("/v1") else f"{raw_url}/v1"
token = auth.get("api_key") or auth.get("token") or os.environ.get("OLLAMA_API_KEY")
headers = {
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
# 1. Try native Ollama endpoint /api/tags
try:
req = urllib.request.Request(f"{native_host}/api/tags", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("models") or []
models = []
if isinstance(items, list):
for m in items:
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
if name:
models.append(str(name))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
# 2. Try OpenAI-compatible endpoint /v1/models
try:
req = urllib.request.Request(f"{v1_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("data") or data.get("models") or []
models = []
if isinstance(items, list):
for m in items:
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
if mid:
models.append(str(mid))
if models:
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
return None, last_err or "Не удалось подключиться к серверу Ollama"
elif prov in ("local", "local-llm", "llama.cpp", "vllm"):
profiles = self._get_provider_candidate_profiles("local")
last_err = None
for pid, pcfg in profiles:
auth = ProfileAuthManager.load_profile_auth("local", pid) or {}
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("LOCAL_LLM_BASE_URL") or "http://127.0.0.1:8081/v1"
base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
base_url = f"http://{base_url}"
@ -348,10 +664,7 @@ class ModelDiscoveryService:
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
)
req = urllib.request.Request(f"{base_url}/models", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("data") or data.get("models") or []
@ -362,9 +675,13 @@ class ModelDiscoveryService:
if m
]
if models:
return sorted(models)
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
except Exception as exc:
last_err = str(exc)
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
return None
return None, f"Неизвестный провайдер: {provider}"

View file

@ -316,8 +316,8 @@ class ProfileAuthManager:
except Exception as e:
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
# For Local provider, synchronize custom_base_url in router_profiles.yaml
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
base_url = auth_data.get("base_url")
if base_url:
try:
@ -442,6 +442,18 @@ class ProfileAuthManager:
if val:
return {"provider": provider, "profile_id": profile_id, "base_url": val}
elif provider in ("openrouter",):
env_var = f"OPENROUTER_API_KEY_{profile_id.upper().replace('-', '_')}"
val = os.environ.get(env_var) or os.environ.get("OPENROUTER_API_KEY")
if val:
return {"provider": "openrouter", "profile_id": profile_id, "api_key": val}
elif provider in ("nvidia", "nvidia-nim"):
env_var = f"NVIDIA_API_KEY_{profile_id.upper().replace('-', '_')}"
val = os.environ.get(env_var) or os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
if val:
return {"provider": provider, "profile_id": profile_id, "api_key": val}
return None
@classmethod
@ -844,6 +856,34 @@ class ProfileAuthManager:
"error": None if is_auth else "URL сервера не настроен",
}
elif provider in ("openrouter",):
key = auth_data.get("api_key") or auth_data.get("token") or ""
is_auth = bool(key)
masked = f"sk-or-...{key[-4:]}" if len(key) > 8 else ("sk-or-***" if key else None)
return {
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"auth_mode": "api_key",
"account_id_masked": masked or "Not configured",
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
"error": None if is_auth else "API-ключ не настроен",
}
elif provider in ("nvidia", "nvidia-nim"):
key = auth_data.get("api_key") or auth_data.get("token") or ""
is_auth = bool(key)
masked = f"nvapi-...{key[-4:]}" if len(key) > 8 else ("nvapi-***" if key else None)
return {
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"auth_mode": "api_key",
"account_id_masked": masked or "Not configured",
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
"error": None if is_auth else "API-ключ не настроен",
}
return {
"authenticated": False,
"provider": provider,

View file

@ -422,15 +422,16 @@ class UnifiedHealthService:
else:
f_status = STATUS_NOT_CONFIGURED
f_lbl = "Аккаунт не добавлен"
elif f_cd > 0:
f_status = STATUS_QUOTA_EXHAUSTED
f_lbl = f"Квота исчерпана ({f_cd}s)"
elif frec and frec.state == QUOTA_EXHAUSTED:
f_status = STATUS_QUOTA_EXHAUSTED
f_lbl = "Квота исчерпана"
elif frec and frec.state == RATE_LIMITED:
f_status = STATUS_RATE_LIMITED
f_lbl = "Лимит запросов"
f_lbl = f"Лимит запросов ({f_cd}s)" if f_cd > 0 else "Лимит запросов"
elif frec and frec.state == QUOTA_EXHAUSTED:
f_status = STATUS_QUOTA_EXHAUSTED
f_lbl = f"Квота исчерпана ({f_cd}s)" if f_cd > 0 else "Квота исчерпана"
elif f_cd > 0 or (frec and frec.state == COOLDOWN):
f_status = STATUS_COOLDOWN
reason_suffix = f": {frec.reason}" if (frec and frec.reason) else ""
f_lbl = f"Откат ({f_cd}s){reason_suffix}" if f_cd > 0 else "Откат"
elif frec and frec.state == HT_UNHEALTHY:
f_status = STATUS_UNHEALTHY
f_lbl = "Ошибка"
@ -464,19 +465,32 @@ class UnifiedHealthService:
else:
health_state = STATUS_NOT_CONFIGURED
health_lbl = "Аккаунт не добавлен"
# 3. Active Cooldown / Quota exhausted
elif max_cd > 0 or precord.overall_state == QUOTA_EXHAUSTED:
health_state = STATUS_QUOTA_EXHAUSTED
health_lbl = "Квота исчерпана"
# 4. Rate limited
elif precord.overall_state == RATE_LIMITED:
# 3. Rate limited (checked before error cooldown)
elif precord.overall_state == RATE_LIMITED or (model_states and any(m.status == STATUS_RATE_LIMITED for m in model_states.values())):
health_state = STATUS_RATE_LIMITED
health_lbl = "Лимит запросов"
# 5. Unhealthy probe error
elif precord.overall_state == HT_UNHEALTHY:
health_lbl = f"Лимит запросов ({max_cd}s)" if max_cd > 0 else "Лимит запросов"
# 4. Quota exhausted
elif precord.overall_state == QUOTA_EXHAUSTED or (model_states and all(m.status == STATUS_QUOTA_EXHAUSTED for m in model_states.values())):
health_state = STATUS_QUOTA_EXHAUSTED
health_lbl = f"Квота исчерпана ({max_cd}s)" if max_cd > 0 else "Квота исчерпана"
# 5. Temporary error cooldown / rollback (shows cooldown and reason, not quota)
elif max_cd > 0 or precord.overall_state == COOLDOWN or (model_states and any(m.status == STATUS_COOLDOWN for m in model_states.values())):
health_state = STATUS_COOLDOWN
cooldown_reason = precord.last_error
if not cooldown_reason:
for m in model_states.values():
if m.reason:
cooldown_reason = m.reason
break
if cooldown_reason:
health_lbl = f"Откат ({max_cd}s): {cooldown_reason}"
else:
health_lbl = f"Откат ({max_cd}s)"
# 6. Unhealthy probe error
elif precord.overall_state == HT_UNHEALTHY or (model_states and any(m.status == STATUS_UNHEALTHY for m in model_states.values())):
health_state = STATUS_UNHEALTHY
health_lbl = "Ошибка"
# 6. Live healthy or untested
# 7. Live healthy or untested
else:
if precord.last_success is not None:
health_state = STATUS_HEALTHY
@ -500,6 +514,14 @@ class UnifiedHealthService:
"anthropic": "Claude",
"grok": "Grok",
"xai": "Grok",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"nvidia-nim": "NVIDIA NIM",
"ollama": "Ollama",
"local": "Local LLM",
"local-llm": "Local LLM",
"llama.cpp": "Local LLM (llama.cpp)",
"vllm": "vLLM",
}.get(prov.lower(), prov)
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None

View file

@ -0,0 +1,518 @@
"""Tests for A42: Real provider connection, model discovery, quota and health fixes."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import time
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.adapters.base_adapter import ErrorCategory
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.health_tracker import (
HEALTHY,
QUOTA_EXHAUSTED,
RATE_LIMITED,
COOLDOWN,
HealthTracker,
)
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
from antigravity_provider.router.unified_health import (
STATUS_COOLDOWN,
STATUS_HEALTHY,
STATUS_QUOTA_EXHAUSTED,
STATUS_RATE_LIMITED,
STATUS_UNHEALTHY,
UnifiedHealthService,
)
@pytest.fixture(autouse=True)
def setup_test_environment(tmp_path, monkeypatch):
"""Isolate Hermes home and configuration for all tests."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
profiles_dir = hermes_home / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("HOME", str(tmp_path))
# Initialize empty config
cfg = RouterConfig()
save_router_config(cfg)
yield hermes_home
# ═════════════════════════════════════════════════════════════════════════════
# P0-1: Real connection of OpenRouter, NVIDIA, Ollama, Claude, Local
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.unit
def test_p0_1_auto_assigner_provider_slots_and_capabilities():
"""Verify openrouter, nvidia, nvidia-nim are present in provider_slots and capabilities_map."""
slot_or = AutoAssigner.find_free_slot("openrouter")
assert slot_or in ("openrouter-1", "openrouter-2")
slot_nv = AutoAssigner.find_free_slot("nvidia")
assert slot_nv in ("nvidia-1", "nvidia-2")
slot_nim = AutoAssigner.find_free_slot("nvidia-nim")
# nvidia и nvidia-nim — псевдонимы одного провайдера с одним адаптером,
# поэтому слоты у них общие: разводить параллельные наборы профилей
# для одного провайдера означало бы вернуть мышление слотами (A41).
assert slot_nim in ("nvidia-1", "nvidia-2", "nvidia-nim-1", "nvidia-nim-2")
# Capabilities definition
ok, _ = AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
assert ok is True
cfg = load_router_config()
pcfg = cfg.get_profile("openrouter-1")
assert pcfg is not None
assert "coding" in pcfg.capabilities
ok2, _ = AutoAssigner.ensure_profile_definition("nvidia", "nvidia-1")
assert ok2 is True
cfg2 = load_router_config()
pcfg2 = cfg2.get_profile("nvidia-1")
assert pcfg2 is not None
assert "reasoning" in pcfg2.capabilities
@pytest.mark.unit
def test_p0_1_add_account_openrouter_default_base_url():
"""Verify add_account for openrouter substitutes default base_url and saves auth."""
res = ActionExecutor.execute(
"add_account",
{
"provider": "openrouter",
"token": "sk-or-v1-test-key-12345",
"base_url": "", # Empty base url
"target_role": "developer-1",
},
)
assert res["ok"] is True
assert "успешно подключен" in res["message"]
# Verify profile created and auth saved
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
assert auth is not None
assert auth["api_key"] == "sk-or-v1-test-key-12345"
assert auth["base_url"] == "https://openrouter.ai/api/v1"
status = ProfileAuthManager.get_profile_status("openrouter", "openrouter-1")
assert status["authenticated"] is True
assert "sk-or-" in status["account_id_masked"]
@pytest.mark.unit
def test_p0_1_add_account_nvidia_default_base_url():
"""Verify add_account for nvidia substitutes default base_url and saves auth."""
res = ActionExecutor.execute(
"add_account",
{
"provider": "nvidia",
"token": "nvapi-test-key-abcde",
"base_url": "",
"target_role": "developer-1",
},
)
assert res["ok"] is True
assert "успешно подключен" in res["message"]
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
assert auth is not None
assert auth["api_key"] == "nvapi-test-key-abcde"
assert auth["base_url"] == "https://integrate.api.nvidia.com/v1"
status = ProfileAuthManager.get_profile_status("nvidia", "nvidia-1")
assert status["authenticated"] is True
@pytest.mark.unit
def test_p0_1_add_account_ollama_default_base_url():
"""Verify add_account for ollama substitutes default base_url http://127.0.0.1:11434."""
res = ActionExecutor.execute(
"add_account",
{
"provider": "ollama",
"base_url": "", # left empty
"target_role": "developer-1",
},
)
assert res["ok"] is True
auth = ProfileAuthManager.load_profile_auth("ollama", "ollama-1")
assert auth is not None
assert auth["base_url"] == "http://127.0.0.1:11434"
@pytest.mark.unit
def test_p0_1_add_account_local_default_base_url():
"""Verify add_account for local substitutes default base_url http://127.0.0.1:8081/v1."""
res = ActionExecutor.execute(
"add_account",
{
"provider": "local",
"base_url": "",
"target_role": "developer-1",
},
)
assert res["ok"] is True
auth = ProfileAuthManager.load_profile_auth("local", "local-1")
assert auth is not None
assert auth["base_url"] == "http://127.0.0.1:8081/v1"
@pytest.mark.unit
def test_p0_1_add_account_claude_and_opencode():
"""Verify add_account for claude and opencode-go with API keys."""
res_claude = ActionExecutor.execute(
"add_account",
{
"provider": "claude",
"token": "sk-ant-api03-test-1234567890",
"target_role": "developer-1",
},
)
assert res_claude["ok"] is True
res_opencode = ActionExecutor.execute(
"add_account",
{
"provider": "opencode-go",
"token": "opencode-test-key-12345",
"target_role": "developer-1",
},
)
assert res_opencode["ok"] is True
@pytest.mark.unit
def test_p0_1_add_account_honest_rejections():
"""Verify add_account returns honest errors and NEVER fake {'ok': True, 'message': 'Навигация'}."""
# Missing API key for openrouter
res_or = ActionExecutor.execute("add_account", {"provider": "openrouter", "token": ""})
assert res_or["ok"] is False
assert "API-ключ" in res_or["message"]
# Missing API key for nvidia
res_nv = ActionExecutor.execute("add_account", {"provider": "nvidia", "token": ""})
assert res_nv["ok"] is False
assert "API-ключ" in res_nv["message"]
# 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"]
# Unsupported provider
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
assert res_unsupp["ok"] is False
assert "не поддерживается" in res_unsupp["message"]
assert res_unsupp.get("message") != "Навигация"
# ═════════════════════════════════════════════════════════════════════════════
# P0-2: Model Discovery for OpenRouter, NVIDIA, Ollama + Error Preservation
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.unit
def test_p0_2_openrouter_discovery_headers(tmp_path):
"""Verify OpenRouter model discovery queries {base_url}/models with required headers."""
cache_file = tmp_path / "models_cache.json"
service = ModelDiscoveryService(cache_path=cache_file)
# Save auth for openrouter profile
auth_data = {
"provider": "openrouter",
"profile_id": "openrouter-1",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test-key",
}
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", auth_data)
AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
captured_request = []
def _mock_urlopen(req, timeout=15):
captured_request.append(req)
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({
"data": [
{"id": "anthropic/claude-3.7-sonnet"},
{"id": "openai/gpt-4o"},
{"id": "deepseek/deepseek-r1"},
]
}).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
return mock_resp
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
models = service.discover_models_sync("openrouter", timeout=5.0)
assert models is not None
assert "anthropic/claude-3.7-sonnet" in models
assert "openai/gpt-4o" in models
assert len(captured_request) == 1
req = captured_request[0]
assert req.full_url == "https://openrouter.ai/api/v1/models"
assert req.headers.get("Authorization") == "Bearer sk-or-test-key"
assert "Http-referer" in req.headers or "HTTP-Referer" in req.headers
assert "X-openrouter-title" in req.headers or "X-OpenRouter-Title" in req.headers
@pytest.mark.unit
def test_p0_2_nvidia_discovery(tmp_path):
"""Verify NVIDIA model discovery queries {base_url}/models with Bearer token."""
cache_file = tmp_path / "models_cache.json"
service = ModelDiscoveryService(cache_path=cache_file)
auth_data = {
"provider": "nvidia",
"profile_id": "nvidia-1",
"base_url": "https://integrate.api.nvidia.com/v1",
"api_key": "nvapi-test-key",
}
ProfileAuthManager.save_profile_auth("nvidia", "nvidia-1", auth_data)
AutoAssigner.ensure_profile_definition("nvidia", "nvidia-1")
captured_request = []
def _mock_urlopen(req, timeout=15):
captured_request.append(req)
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({
"data": [
{"id": "meta/llama-3.1-405b-instruct"},
{"id": "nvidia/nemotron-4-340b-instruct"},
]
}).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
return mock_resp
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
models = service.discover_models_sync("nvidia", timeout=5.0)
assert models is not None
assert "meta/llama-3.1-405b-instruct" in models
assert captured_request[0].headers.get("Authorization") == "Bearer nvapi-test-key"
@pytest.mark.unit
def test_p0_2_ollama_native_tags_discovery(tmp_path):
"""Verify Ollama discovery queries native endpoint /api/tags."""
cache_file = tmp_path / "models_cache.json"
service = ModelDiscoveryService(cache_path=cache_file)
auth_data = {
"provider": "ollama",
"profile_id": "ollama-1",
"base_url": "http://127.0.0.1:11434",
}
ProfileAuthManager.save_profile_auth("ollama", "ollama-1", auth_data)
AutoAssigner.ensure_profile_definition("ollama", "ollama-1")
def _mock_urlopen(req, timeout=5):
if "/api/tags" in req.full_url:
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({
"models": [
{"name": "llama3.3:latest"},
{"name": "qwen2.5-coder:32b"},
]
}).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
return mock_resp
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None)
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
models = service.discover_models_sync("ollama", timeout=5.0)
assert models is not None
assert "llama3.3:latest" in models
assert "qwen2.5-coder:32b" in models
@pytest.mark.unit
def test_p0_2_discovery_error_preservation(tmp_path):
"""Verify exact HTTP / connection error is preserved in cache for UI display."""
cache_file = tmp_path / "models_cache.json"
service = ModelDiscoveryService(cache_path=cache_file)
auth_data = {
"provider": "openrouter",
"profile_id": "openrouter-1",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-invalid-key",
}
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", auth_data)
AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
err_body = json.dumps({"error": {"message": "Invalid API key provided"}}).encode("utf-8")
http_error = urllib.error.HTTPError(
url="https://openrouter.ai/api/v1/models",
code=401,
msg="Unauthorized",
hdrs={},
fp=MagicMock(read=MagicMock(return_value=err_body)),
)
with patch("urllib.request.urlopen", side_effect=http_error):
res = service.discover_models_sync("openrouter", timeout=5.0)
assert res is None
error_msg = service.get_error("openrouter")
assert error_msg is not None
assert "401" in error_msg
assert "Invalid API key" in error_msg
meta = service.get_models_with_metadata("openrouter")
assert meta["error"] == error_msg
# ═════════════════════════════════════════════════════════════════════════════
# P0-3: Health & Quota Status Fixes
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.unit
def test_p0_3_cooldown_vs_quota_exhausted_separation(tmp_path):
"""Verify temporary error cooldown (frec.reset_at > now) is STATUS_COOLDOWN, not QUOTA_EXHAUSTED."""
cfg = RouterConfig()
cfg.profiles["codex-orch"] = RouterProfileConfig(
profile_id="codex-orch",
provider="openai-codex",
account_id="codex-orch",
preferred_models=["gpt-4o"],
enabled=True,
)
save_router_config(cfg)
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-test", "provider": "openai-codex"})
from antigravity_provider.router.router_engine import get_router_engine
from antigravity_provider.router.health_tracker import FamilyHealthRecord
engine = get_router_engine()
now = time.time()
rec = engine.health.get_or_create("codex-orch")
rec.overall_state = HEALTHY
rec.last_error = "Server 500 Error"
rec.families["gpt"] = FamilyHealthRecord(
family="gpt",
state=COOLDOWN,
reset_at=now + 120,
reason="500 Server Error Backoff",
)
service = UnifiedHealthService.get()
profiles = service.scan_all(force=True)
codex_vm = next((p for p in profiles["openai-codex"] if p.profile_id == "codex-orch"), None)
assert codex_vm is not None
# Must be COOLDOWN, NOT QUOTA_EXHAUSTED
assert codex_vm.health_state == STATUS_COOLDOWN
assert "Откат" in codex_vm.health_label_ru
assert "Квота исчерпана" not in codex_vm.health_label_ru
@pytest.mark.unit
def test_p0_3_rate_limited_checked_before_cooldown(tmp_path):
"""Verify RATE_LIMITED is prioritized before error cooldowns."""
cfg = RouterConfig()
cfg.profiles["codex-orch"] = RouterProfileConfig(
profile_id="codex-orch",
provider="openai-codex",
account_id="codex-orch",
preferred_models=["gpt-4o"],
enabled=True,
)
save_router_config(cfg)
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-test", "provider": "openai-codex"})
from antigravity_provider.router.router_engine import get_router_engine
from antigravity_provider.router.health_tracker import FamilyHealthRecord
engine = get_router_engine()
now = time.time()
rec = engine.health.get_or_create("codex-orch")
rec.overall_state = RATE_LIMITED
rec.last_error = "429 Too Many Requests"
rec.families["gpt"] = FamilyHealthRecord(
family="gpt",
state=RATE_LIMITED,
reset_at=now + 60,
reason="Rate Limit 429",
)
service = UnifiedHealthService.get()
profiles = service.scan_all(force=True)
codex_vm = next((p for p in profiles["openai-codex"] if p.profile_id == "codex-orch"), None)
assert codex_vm is not None
assert codex_vm.health_state == STATUS_RATE_LIMITED
assert "Лимит запросов" in codex_vm.health_label_ru
@pytest.mark.unit
def test_p0_3_health_tracker_no_overall_quota_on_missing_model():
"""Verify HealthTracker.mark_quota_exhausted does NOT set overall_state = QUOTA_EXHAUSTED on missing/default model."""
ht = HealthTracker()
rec = ht.get_or_create("codex-orch")
rec.overall_state = HEALTHY
# Call with model_name=None
ht.mark_quota_exhausted("codex-orch", model_name=None, duration=600, reason="Test")
assert rec.overall_state == HEALTHY
# Call with model_name="default"
ht.mark_quota_exhausted("codex-orch", model_name="default", duration=600, reason="Test")
assert rec.overall_state == HEALTHY
@pytest.mark.unit
def test_p0_3_codex_adapter_classify_error():
"""Verify CodexAdapter.classify_error correctly distinguishes rate limit, auth, transient, and quota."""
adapter = CodexAdapter()
# Rate limited
c1 = adapter.classify_error(Exception("HTTP 429: Too Many Requests"))
assert c1.category == ErrorCategory.RATE_LIMITED
c2 = adapter.classify_error(Exception("Rate limit reached for requests per min (RPM)"))
assert c2.category == ErrorCategory.RATE_LIMITED
# Auth
c3 = adapter.classify_error(Exception("HTTP 401: Invalid API key"))
assert c3.category == ErrorCategory.AUTH_REQUIRED
# Real Quota
c4 = adapter.classify_error(Exception("You exceeded your current quota, please check your plan and billing details."))
assert c4.category == ErrorCategory.QUOTA_EXHAUSTED
c5 = adapter.classify_error(Exception("insufficient_quota"))
assert c5.category == ErrorCategory.QUOTA_EXHAUSTED
# Transient
c6 = adapter.classify_error(Exception("HTTP 502: Bad Gateway"))
assert c6.category == ErrorCategory.TRANSIENT
c7 = adapter.classify_error(Exception("Connection reset by peer"))
assert c7.category == ErrorCategory.TRANSIENT

View file

@ -43,7 +43,21 @@ def test_profile_view_model_mapping():
assert isinstance(p, ProfileViewModel)
assert p.profile_id
assert p.display_name
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm")
assert p.provider in (
"antigravity",
"openai-codex",
"opencode-go",
"claude",
"grok",
"local",
"openrouter",
"nvidia",
"nvidia-nim",
"ollama",
"local-llm",
"llama.cpp",
"vllm",
)
assert p.health_state in (
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"