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': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}} return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
# Подключение аккаунта: для локального сервера/Ollama это не навигация, а # Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
# настоящее сохранение профиля с адресом.
if action == 'add_account': 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') target_role = data.get('target_role', 'coder-primary')
base_url = data.get('base_url', '') base_url = (data.get('base_url') or '').strip()
token = data.get('token', '') token = (data.get('token') or data.get('api_key') or '').strip()
if prov in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm') and base_url: slot = data.get('profile_id')
slot = data.get('profile_id') or AutoAssigner.find_free_slot(prov) or f'{prov}-1'
AutoAssigner.ensure_profile_definition(prov, slot) default_base_urls = {
auth_data = { 'openrouter': 'https://openrouter.ai/api/v1',
"provider": prov, '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, "profile_id": slot,
"base_url": base_url,
"api_key": token if token else None,
"created_at": time.time(), "created_at": time.time(),
} }
ProfileAuthManager.save_profile_auth(prov, slot, auth_data) 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) AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth() _rescan_after_auth()
return {'ok': True, 'message': f'Сервер {prov} ({slot}) успешно подключен'} return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
return {'ok': True, 'message': 'Навигация'} except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят: # Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу — # A25 внёс их в этот список, но в A24 они выполняют настоящую работу —

View file

@ -128,8 +128,23 @@ class CodexAdapter(BaseProviderAdapter):
err_msg = str(exc) err_msg = str(exc)
err_lower = err_msg.lower() err_lower = err_msg.lower()
# Quota / usage limit # 1. Rate limited (429) - must take precedence over generic limit checks
if any(k in err_lower for k in ("quota", "insufficient_quota", "usage_limit", "exceeded your current quota")): 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 reset_sec = 1800
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower) m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
if m_sec: if m_sec:
@ -140,23 +155,8 @@ class CodexAdapter(BaseProviderAdapter):
reset_duration_seconds=reset_sec, reset_duration_seconds=reset_sec,
) )
# Rate limited (429) # 4. Transient network / server errors
if "429" in err_lower or "rate limit" in err_lower or "tokens per min" in err_lower: if any(k in err_lower for k in ("timeout", "500", "502", "503", "504", "connection reset", "connection refused", "transport error")):
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")):
return ErrorClassification( return ErrorClassification(
category=ErrorCategory.TRANSIENT, category=ErrorCategory.TRANSIENT,
message=err_msg, message=err_msg,

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path 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") logger = logging.getLogger("hermes.router.model_discovery")
@ -93,18 +93,27 @@ class ModelDiscoveryService:
"discovered_at": None, "discovered_at": None,
"is_stale": True, "is_stale": True,
"has_cache": False, "has_cache": False,
"error": entry.get("error") if entry else None,
} }
discovered_at = entry.get("discovered_at", 0) discovered_at = entry.get("discovered_at")
is_stale = (time.time() - discovered_at) > self._ttl_seconds is_stale = (time.time() - float(discovered_at)) > self._ttl_seconds if discovered_at else True
models = entry.get("models")
return { return {
"provider": provider, "provider": provider,
"models": list(entry["models"]), "models": list(models) if models else None,
"discovered_at": discovered_at, "discovered_at": discovered_at,
"is_stale": is_stale, "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]: def get_cached(self, provider: str) -> Dict[str, Any]:
"""Convenience alias for get_models_with_metadata.""" """Convenience alias for get_models_with_metadata."""
return self.get_models_with_metadata(provider) return self.get_models_with_metadata(provider)
@ -144,9 +153,19 @@ class ModelDiscoveryService:
on_complete: Optional[Callable[[Dict[str, Optional[List[str]]]], None]] = None, on_complete: Optional[Callable[[Dict[str, Optional[List[str]]]], None]] = None,
timeout: float = 15.0, timeout: float = 15.0,
) -> None: ) -> None:
"""Discover models for all 5 providers concurrently in background.""" """Discover models for all configured providers concurrently in background."""
def _worker(): 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]]] = {} results: Dict[str, Optional[List[str]]] = {}
threads = [] threads = []
@ -172,13 +191,15 @@ class ModelDiscoveryService:
def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]: def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]:
"""Synchronously probe models with strict timeout without blocking indefinite hangs.""" """Synchronously probe models with strict timeout without blocking indefinite hangs."""
result_holder: List[Optional[List[str]]] = [None] result_holder: List[Optional[List[str]]] = [None]
error_holder: List[Optional[Exception]] = [None] error_holder: List[Optional[str]] = [None]
def _do_probe(): def _do_probe():
try: 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: except Exception as exc:
error_holder[0] = exc error_holder[0] = str(exc)
worker = threading.Thread(target=_do_probe, daemon=True) worker = threading.Thread(target=_do_probe, daemon=True)
worker.start() worker.start()
@ -186,51 +207,139 @@ class ModelDiscoveryService:
if worker.is_alive(): if worker.is_alive():
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout) 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: with self._cache_lock:
entry = self._cache.get(provider.lower()) entry = self._cache.get(provider.lower(), {})
return list(entry["models"]) if entry and "models" in entry else None existing_models = entry.get("models")
self._cache[provider.lower()] = {
if error_holder[0]: "models": existing_models,
logger.info("Model discovery probe for '%s' returned error: %s", provider, error_holder[0]) "discovered_at": entry.get("discovered_at"),
with self._cache_lock: "error": timeout_msg,
entry = self._cache.get(provider.lower()) }
return list(entry["models"]) if entry and "models" in entry else None self._save_cache_to_disk()
return list(existing_models) if existing_models else None
models = result_holder[0] models = result_holder[0]
err_text = error_holder[0]
if models: if models:
with self._cache_lock: with self._cache_lock:
self._cache[provider.lower()] = { self._cache[provider.lower()] = {
"models": models, "models": models,
"discovered_at": time.time(), "discovered_at": time.time(),
"error": None,
} }
self._save_cache_to_disk() self._save_cache_to_disk()
logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models) logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models)
return models return models
# If probe returned None or empty, retain existing cache if any if err_text:
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
with self._cache_lock: with self._cache_lock:
entry = self._cache.get(provider.lower()) entry = self._cache.get(provider.lower(), {})
return list(entry["models"]) if entry and "models" in entry else None 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]]: with self._cache_lock:
"""Perform provider-specific model discovery.""" 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() prov = provider.lower()
from antigravity_provider.router.profile_manager import ProfileAuthManager 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 from antigravity_provider.agy_subprocess import discover_models
main_p = ProfileAuthManager.get_main_profile("antigravity") main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
try:
res = discover_models(profile_id=main_p) res = discover_models(profile_id=main_p)
if res: if res:
return sorted(list(set(res.values()))) return sorted(list(set(res.values()))), None
return None return None, "Модели Google Antigravity не обнаружены"
except Exception as exc:
return None, str(exc)
elif prov in ("openai-codex", "codex"): elif prov in ("openai-codex", "codex"):
for pid in ["codex-orch", "codex-worker-1", "codex-worker-2"]: profiles = self._get_provider_candidate_profiles("openai-codex")
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid) last_err = None
if not auth: for pid, pcfg in profiles:
continue auth = ProfileAuthManager.load_profile_auth("openai-codex", pid) or {}
tokens = auth.get("token") or auth.get("tokens") or auth tokens = auth.get("token") or auth.get("tokens") or auth
access_token = ( access_token = (
tokens.get("access_token") tokens.get("access_token")
@ -239,9 +348,13 @@ class ModelDiscoveryService:
) )
if not access_token: if not access_token:
continue 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: try:
req = urllib.request.Request( req = urllib.request.Request(
"https://api.openai.com/v1/models", f"{base_url}/models",
headers={ headers={
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Accept": "application/json", "Accept": "application/json",
@ -257,22 +370,31 @@ class ModelDiscoveryService:
m for m in models m for m in models
if any(x in m for x in ("gpt-4", "gpt-3.5", "o1", "o3", "codex", "chatgpt")) 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: except Exception as exc:
last_err = str(exc)
logger.debug("Codex model query failed on %s: %s", pid, 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"): elif prov in ("opencode-go", "opencode"):
for pid in ["opengo-1", "opengo-2", "opengo-3"]: profiles = self._get_provider_candidate_profiles("opencode-go")
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid) last_err = None
if not auth: for pid, pcfg in profiles:
continue auth = ProfileAuthManager.load_profile_auth("opencode-go", pid) or {}
api_key = auth.get("api_key") api_key = auth.get("api_key")
if not api_key: if not api_key:
continue 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: try:
req = urllib.request.Request( req = urllib.request.Request(
"https://opencode.ai/zen/go/v1/models", f"{base_url}/models",
headers={ headers={
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Accept": "application/json", "Accept": "application/json",
@ -284,32 +406,36 @@ class ModelDiscoveryService:
items = data.get("data") or data.get("models") or [] items = data.get("data") or data.get("models") or []
if isinstance(items, list): if isinstance(items, list):
models = [str(m.get("id") or m) for m in items if m] 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: except Exception as exc:
last_err = str(exc)
logger.debug("OpenCode model query failed on %s: %s", pid, exc) logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
elif prov == "grok": elif prov in ("grok", "xai"):
# Провайдера здесь не было вовсе, поэтому кэш моделей Grok всегда profiles = self._get_provider_candidate_profiles("grok")
# оставался пустым, и выбор модели отвергал даже настоящие имена: last_err = None
# «модель grok-4.5 не найдена в списке известных». При этом for pid, pcfg in profiles:
# api.x.ai/v1/models принимает тот же OAuth-токен, что и вызовы, и auth = ProfileAuthManager.load_profile_auth("grok", pid) or {}
# отдаёт полный список.
for pid in ("grok-orch", "grok-worker-1", "grok-worker-2"):
auth = ProfileAuthManager.load_profile_auth("grok", pid)
if not auth:
continue
tokens = auth.get("token") or auth.get("tokens") or {} tokens = auth.get("token") or auth.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None token = tokens.get("access_token") if isinstance(tokens, dict) else None
token = token or auth.get("access_token") or auth.get("api_key") token = token or auth.get("access_token") or auth.get("api_key")
if not token: if not token:
continue 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: try:
request = urllib.request.Request( req = urllib.request.Request(
"https://api.x.ai/v1/models", f"{base_url}/models",
headers={"Authorization": f"Bearer {token}"}, 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 "{}") payload = json.loads(response.read().decode("utf-8") or "{}")
models = [ models = [
str(item.get("id")) str(item.get("id"))
@ -317,25 +443,215 @@ class ModelDiscoveryService:
if isinstance(item, dict) and item.get("id") if isinstance(item, dict) and item.get("id")
] ]
if models: 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: except Exception as exc:
last_err = str(exc)
logger.debug("Grok model discovery failed for %s: %s", pid, 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"): elif prov in ("claude", "anthropic"):
from antigravity_provider.router.router_config import load_router_config profiles = self._get_provider_candidate_profiles("claude")
cfg = load_router_config() last_err = None
for pid in ["local-1", "local-2"]: for pid, pcfg in profiles:
pcfg = cfg.get_profile(pid) auth = ProfileAuthManager.load_profile_auth("claude", pid) or {}
auth = ProfileAuthManager.load_profile_auth("local", pid) or {} tokens = auth.get("token") or auth.get("tokens") or {}
base_url = ( token = tokens.get("access_token") if isinstance(tokens, dict) else None
(pcfg.custom_base_url if pcfg 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")
or auth.get("base_url") if not token:
or os.environ.get("LOCAL_LLM_BASE_URL")
or "http://127.0.0.1:8081/v1"
)
if not base_url:
continue 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("/") base_url = str(base_url).strip().rstrip("/")
if not base_url.startswith(("http://", "https://")): if not base_url.startswith(("http://", "https://")):
base_url = f"http://{base_url}" base_url = f"http://{base_url}"
@ -348,10 +664,7 @@ class ModelDiscoveryService:
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
try: try:
req = urllib.request.Request( req = urllib.request.Request(f"{base_url}/models", headers=headers)
f"{base_url}/models",
headers=headers,
)
with urllib.request.urlopen(req, timeout=5) as resp: with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}") data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
items = data.get("data") or data.get("models") or [] items = data.get("data") or data.get("models") or []
@ -362,9 +675,13 @@ class ModelDiscoveryService:
if m if m
] ]
if models: 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: except Exception as exc:
last_err = str(exc)
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, 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: except Exception as e:
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, 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 # For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
base_url = auth_data.get("base_url") base_url = auth_data.get("base_url")
if base_url: if base_url:
try: try:
@ -442,6 +442,18 @@ class ProfileAuthManager:
if val: if val:
return {"provider": provider, "profile_id": profile_id, "base_url": 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 return None
@classmethod @classmethod
@ -844,6 +856,34 @@ class ProfileAuthManager:
"error": None if is_auth else "URL сервера не настроен", "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 { return {
"authenticated": False, "authenticated": False,
"provider": provider, "provider": provider,

View file

@ -422,15 +422,16 @@ class UnifiedHealthService:
else: else:
f_status = STATUS_NOT_CONFIGURED f_status = STATUS_NOT_CONFIGURED
f_lbl = "Аккаунт не добавлен" 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: elif frec and frec.state == RATE_LIMITED:
f_status = STATUS_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: elif frec and frec.state == HT_UNHEALTHY:
f_status = STATUS_UNHEALTHY f_status = STATUS_UNHEALTHY
f_lbl = "Ошибка" f_lbl = "Ошибка"
@ -464,19 +465,32 @@ class UnifiedHealthService:
else: else:
health_state = STATUS_NOT_CONFIGURED health_state = STATUS_NOT_CONFIGURED
health_lbl = "Аккаунт не добавлен" health_lbl = "Аккаунт не добавлен"
# 3. Active Cooldown / Quota exhausted # 3. Rate limited (checked before error cooldown)
elif max_cd > 0 or precord.overall_state == QUOTA_EXHAUSTED: 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_QUOTA_EXHAUSTED
health_lbl = "Квота исчерпана"
# 4. Rate limited
elif precord.overall_state == RATE_LIMITED:
health_state = STATUS_RATE_LIMITED health_state = STATUS_RATE_LIMITED
health_lbl = "Лимит запросов" health_lbl = f"Лимит запросов ({max_cd}s)" if max_cd > 0 else "Лимит запросов"
# 5. Unhealthy probe error # 4. Quota exhausted
elif precord.overall_state == HT_UNHEALTHY: 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_state = STATUS_UNHEALTHY
health_lbl = "Ошибка" health_lbl = "Ошибка"
# 6. Live healthy or untested # 7. Live healthy or untested
else: else:
if precord.last_success is not None: if precord.last_success is not None:
health_state = STATUS_HEALTHY health_state = STATUS_HEALTHY
@ -500,6 +514,14 @@ class UnifiedHealthService:
"anthropic": "Claude", "anthropic": "Claude",
"grok": "Grok", "grok": "Grok",
"xai": "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) }.get(prov.lower(), prov)
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None 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 isinstance(p, ProfileViewModel)
assert p.profile_id assert p.profile_id
assert p.display_name 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 ( assert p.health_state in (
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited", "healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured" "auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"