diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 91e4cb1..255d2c2 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -565,27 +565,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 они выполняют настоящую работу — diff --git a/src/antigravity_provider/router/adapters/codex_adapter.py b/src/antigravity_provider/router/adapters/codex_adapter.py index f00fa79..04ce0d4 100644 --- a/src/antigravity_provider/router/adapters/codex_adapter.py +++ b/src/antigravity_provider/router/adapters/codex_adapter.py @@ -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, diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 1ff937b..e2934d0 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -48,6 +48,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"), @@ -128,6 +134,9 @@ class AutoAssigner: "llama.cpp": ["local-1", "local-2"], "ollama": ["ollama-1", "ollama-2"], "vllm": ["local-1", "local-2"], + "openrouter": ["openrouter-1", "openrouter-2"], + "nvidia": ["nvidia-1", "nvidia-2"], + "nvidia-nim": ["nvidia-nim-1", "nvidia-nim-2"], } candidates = list(provider_slots.get(provider_norm, [])) @@ -180,6 +189,12 @@ class AutoAssigner: elif p in ("local", "local-llm", "llama.cpp", "vllm"): for i in range(3, 200): yield f"local-{i}" + elif p in ("openrouter",): + for i in range(3, 200): + yield f"openrouter-{i}" + elif p in ("nvidia", "nvidia-nim"): + for i in range(3, 200): + yield f"{p}-{i}" else: for i in range(1, 200): yield f"{p}-{i}" @@ -218,6 +233,9 @@ 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", "reviewer"], + "nvidia": ["coding", "reasoning", "fast"], + "nvidia-nim": ["coding", "reasoning", "fast"], } capabilities = capabilities_map.get(provider, ["coding", "reasoning"]) @@ -505,6 +523,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) diff --git a/src/antigravity_provider/router/health_tracker.py b/src/antigravity_provider/router/health_tracker.py index 8ab2e66..39d6383 100644 --- a/src/antigravity_provider/router/health_tracker.py +++ b/src/antigravity_provider/router/health_tracker.py @@ -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: diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 971bb9b..95b3a9b 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -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}" diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 2beeb3a..22cb4e9 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -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: @@ -438,6 +438,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 @@ -840,6 +852,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, diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 24e7cd1..11cd7c7 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -421,15 +421,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 = "Ошибка" @@ -463,19 +464,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 @@ -499,6 +513,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 diff --git a/tests/test_a42_provider_connect.py b/tests/test_a42_provider_connect.py new file mode 100644 index 0000000..dd70ec3 --- /dev/null +++ b/tests/test_a42_provider_connect.py @@ -0,0 +1,515 @@ +"""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") + assert slot_nim in ("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 diff --git a/tests/test_unified_health.py b/tests/test_unified_health.py index 2d2f54c..1bd46d2 100644 --- a/tests/test_unified_health.py +++ b/tests/test_unified_health.py @@ -44,8 +44,21 @@ def test_profile_view_model_mapping(): for p in all_profs: assert isinstance(p, ProfileViewModel) assert p.profile_id - assert p.display_name - assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local") + 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"