From 87d83251bbf247964f9beb2b65fbbd910e2b35e8 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Mon, 24 Aug 2026 14:00:25 +0700 Subject: [PATCH] feat(router): integrate local LLM provider (llama.cpp/vLLM/Ollama) with zero quotas (A25) --- .../router/account_identity.py | 8 +- .../router/action_handler.py | 24 +- .../router/adapters/__init__.py | 6 + .../router/adapters/local_adapter.py | 207 ++++++++++ .../router/auto_assigner.py | 17 + .../router/model_discovery_service.py | 47 ++- .../router/profile_manager.py | 95 +++++ .../router/quota_collector.py | 50 ++- .../router/router_config.py | 17 + .../router/ui/add_account_wizard.py | 185 +++++++++ .../router/web/static/app.js | 52 ++- tests/test_a9_migration_quotas_models.py | 4 +- tests/test_local_llm_provider_a25.py | 366 ++++++++++++++++++ tests/test_multi_provider_router.py | 4 +- tests/test_unified_health.py | 2 +- 15 files changed, 1070 insertions(+), 14 deletions(-) create mode 100644 src/antigravity_provider/router/adapters/local_adapter.py create mode 100644 tests/test_local_llm_provider_a25.py diff --git a/src/antigravity_provider/router/account_identity.py b/src/antigravity_provider/router/account_identity.py index 8530964..1b675d3 100644 --- a/src/antigravity_provider/router/account_identity.py +++ b/src/antigravity_provider/router/account_identity.py @@ -78,6 +78,8 @@ class SubscriptionPlan: "TIER2": "TIER 2", "SUPERGROK": "SUPERGROK", "GROK PRO": "GROK PRO", + "LOCAL": "LOCAL", + "LOCAL MODEL": "LOCAL MODEL", } disp = display_map.get(cleaned, display_map.get(code_upper, cleaned)) return cls( @@ -172,7 +174,9 @@ class QuotaBucket: self.used_percent = max(0.0, min(100.0, 100.0 - float(self.remaining_percent))) # Auto-determine status - if self.remaining_percent is not None: + if self.status == "unlimited": + pass + elif self.remaining_percent is not None: if self.remaining_percent <= 0.0 or (self.used_percent is not None and self.used_percent >= 100.0): self.status = "exhausted" elif self.remaining_percent < 15.0: @@ -193,6 +197,8 @@ class QuotaBucket: def formatted_remaining(self) -> str: """User-facing unambiguous string.""" + if self.status == "unlimited" or self.period == "unlimited": + return "Без ограничений" if self.remaining_absolute is not None and self.limit_absolute is not None: used = self.used_absolute if self.used_absolute is not None else (self.limit_absolute - self.remaining_absolute) rem_pct_str = f" · Осталось {self.remaining_percent:.0f}%" if self.remaining_percent is not None else "" diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index c6c7e78..585acd3 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -238,9 +238,27 @@ class ActionExecutor: """ pid = data.get('profile_id', '') prov = data.get('provider', '') - - # Purely UI navigation actions return True for Web API (no-op on server side). - if action in ['oauth', 'add_account', 'account_details', 'agent_settings', 'edit_route', 'open_routing', 'assign_role']: + # UI navigation or account persistence actions + if action == 'add_account': + 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 = AutoAssigner.find_free_slot(prov) or 'local-1' + AutoAssigner.ensure_profile_definition(prov, slot) + auth_data = { + "provider": "local", + "profile_id": slot, + "base_url": base_url, + "api_key": token if token else None, + "created_at": time.time(), + } + ProfileAuthManager.save_profile_auth("local", slot, auth_data) + AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False) + return {'ok': True, 'message': f'Локальный сервер {slot} успешно подключен'} + return {'ok': True, 'message': 'Навигация'} + + if action in ['oauth', 'account_details', 'agent_settings', 'edit_route', 'open_routing', 'assign_role']: return {'ok': True, 'message': 'Навигация'} if action == 'set_main': diff --git a/src/antigravity_provider/router/adapters/__init__.py b/src/antigravity_provider/router/adapters/__init__.py index fa1ebb2..9f40b14 100644 --- a/src/antigravity_provider/router/adapters/__init__.py +++ b/src/antigravity_provider/router/adapters/__init__.py @@ -8,6 +8,7 @@ from .claude_adapter import ClaudeAdapter from .codex_adapter import CodexAdapter from .deepseek_adapter import DeepSeekResponsesAdapter from .grok_adapter import GrokAdapter +from .local_adapter import LocalLLMAdapter from .opencode_adapter import OpenCodeGoAdapter _ADAPTERS: dict[str, BaseProviderAdapter] = { @@ -24,6 +25,11 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = { "xai": GrokAdapter(), "xai-oauth": GrokAdapter(), "deepseek": DeepSeekResponsesAdapter(), + "local": LocalLLMAdapter(), + "local-llm": LocalLLMAdapter(), + "llama.cpp": LocalLLMAdapter(), + "ollama": LocalLLMAdapter(), + "vllm": LocalLLMAdapter(), } diff --git a/src/antigravity_provider/router/adapters/local_adapter.py b/src/antigravity_provider/router/adapters/local_adapter.py new file mode 100644 index 0000000..201009f --- /dev/null +++ b/src/antigravity_provider/router/adapters/local_adapter.py @@ -0,0 +1,207 @@ +"""Local LLM OpenAI-compatible provider adapter for llama.cpp / vLLM / Ollama.""" +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional + +from ..router_config import RouterProfileConfig +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification + +logger = logging.getLogger("hermes.router.adapter.local") + +DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8081/v1" +DEFAULT_LOCAL_MODELS = ["default"] + + +class LocalLLMAdapter(BaseProviderAdapter): + """Adapter for local OpenAI-compatible chat completion servers (llama.cpp, Ollama, vLLM).""" + + def _resolve_base_url(self, profile: RouterProfileConfig) -> str: + """Resolve base_url from profile custom_base_url, auth_config, or environment.""" + url = ( + profile.custom_base_url + or profile.auth_config.get("base_url") + or os.environ.get("LOCAL_LLM_BASE_URL") + or DEFAULT_LOCAL_BASE_URL + ) + url_str = str(url).strip().rstrip("/") + if not url_str.startswith(("http://", "https://")): + url_str = f"http://{url_str}" + return url_str + + def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]: + """Resolve optional API key from profile auth_config or environment.""" + key = profile.auth_config.get("api_key") or profile.auth_config.get("token") + if key: + return str(key).strip() + + suffix = profile.profile_id.upper().replace("-", "_") + for candidate in (f"LOCAL_LLM_API_KEY_{suffix}", "LOCAL_LLM_API_KEY", "LOCAL_API_KEY"): + val = os.environ.get(candidate, "").strip() + if val: + return val + return None + + def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: + base_url = self._resolve_base_url(profile) + api_key = self._resolve_api_key(profile) + + model = request.get("model", "") + if not model or model == "default": + model = profile.preferred_models[0] if profile.preferred_models else "default" + + payload: Dict[str, Any] = { + "model": model, + "messages": request.get("messages", []), + "temperature": request.get("temperature", 0.7), + } + if "tools" in request and request["tools"]: + payload["tools"] = request["tools"] + if "tool_choice" in request: + payload["tool_choice"] = request["tool_choice"] + if "response_format" in request: + payload["response_format"] = request["response_format"] + if "max_tokens" in request: + payload["max_tokens"] = request["max_tokens"] + if "stream" in request: + payload["stream"] = request["stream"] + if "stop" in request: + payload["stop"] = request["stop"] + + headers: Dict[str, str] = { + "Content-Type": "application/json", + "User-Agent": "hermes-router/1.0", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request( + f"{base_url}/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=120) as resp: + return json.loads(resp.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as http_err: + raw_err = http_err.read().decode("utf-8", errors="replace") + try: + err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) + except Exception: + err_msg = raw_err + raise RuntimeError(f"Local LLM API Error ({http_err.code}): {err_msg}") from http_err + except Exception as exc: + raise RuntimeError(f"Local LLM Transport Error: {exc}") from exc + + def discover_models(self, profile: RouterProfileConfig) -> List[str]: + """Request GET {base_url}/models with short timeout (5s) and return list of model IDs.""" + base_url = self._resolve_base_url(profile) + api_key = self._resolve_api_key(profile) + + headers: Dict[str, str] = { + "Accept": "application/json", + "User-Agent": "hermes-router/1.0", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request( + f"{base_url}/models", + headers=headers, + method="GET", + ) + + try: + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read().decode("utf-8", errors="replace")) + items = data.get("data") or data.get("models") or [] + if isinstance(items, list): + models = [ + str(m.get("id") or m.get("name") if isinstance(m, dict) else m) + for m in items + if m + ] + if models: + return sorted(models) + except Exception as exc: + logger.debug("Failed to discover models for local profile %s: %s", profile.profile_id, exc) + + return list(profile.preferred_models or DEFAULT_LOCAL_MODELS) + + def health_check(self, profile: RouterProfileConfig) -> bool: + """Fast GET {base_url}/models probe (2-3s). Returns True on success, False on error.""" + base_url = self._resolve_base_url(profile) + api_key = self._resolve_api_key(profile) + + headers: Dict[str, str] = { + "Accept": "application/json", + "User-Agent": "hermes-router/1.0", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request( + f"{base_url}/models", + headers=headers, + method="GET", + ) + + try: + with urllib.request.urlopen(req, timeout=3) as resp: + return resp.status in (200, 204) + except Exception: + return False + + def classify_error( + self, + exc: Exception, + response_data: Optional[Dict[str, Any]] = None, + ) -> ErrorClassification: + """Classify execution failure into structured error category.""" + err_msg = str(exc) + err_lower = err_msg.lower() + + # 429 Rate limited + if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower: + return ErrorClassification( + category=ErrorCategory.RATE_LIMITED, + message=err_msg, + retry_delay_seconds=30, + ) + + # 401 / 403 Auth required + if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")): + return ErrorClassification( + category=ErrorCategory.AUTH_REQUIRED, + message=err_msg, + ) + + # Quota exhausted + if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")): + return ErrorClassification( + category=ErrorCategory.QUOTA_EXHAUSTED, + message=err_msg, + reset_duration_seconds=1800, + ) + + # Network failures / Connection refused / Timeout / 502, 503, 504 / Transport error + # Classified as TRANSIENT with short retry delay (2s) for instant failover + if any(k in err_lower for k in ( + "connection refused", "connection error", "connect", "refused", + "timeout", "timed out", "502", "503", "504", "gateway", + "econnrefused", "econnreset", "transport error", "urlerror", + "winerror 10061", "nodename nor servname provided", + )): + return ErrorClassification( + category=ErrorCategory.TRANSIENT, + message=err_msg, + retry_delay_seconds=2, + ) + + return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2) diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 55f779f..104b894 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -57,6 +57,8 @@ DEFAULT_SLOT_ROLES = { "opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"), "opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"), "opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"), + "local-1": ("Локальный сервер 1", "coder", "primary"), + "local-2": ("Локальный сервер 2", "fast", "primary"), "ag-spare-1": ("Резерв 1", "spare", "spare"), "ag-spare-2": ("Резерв 2", "spare", "spare"), "ag-cold-1": ("Холодный резерв 1", "spare", "cold"), @@ -148,6 +150,11 @@ class AutoAssigner: "opencode-go": ["opengo-1", "opengo-2", "opengo-3"], "claude": ["claude-orch", "claude-worker-1", "claude-worker-2"], "grok": ["grok-orch", "grok-worker-1", "grok-worker-2"], + "local": ["local-1", "local-2"], + "local-llm": ["local-1", "local-2"], + "llama.cpp": ["local-1", "local-2"], + "ollama": ["local-1", "local-2"], + "vllm": ["local-1", "local-2"], } candidates = list(provider_slots.get(provider, [])) @@ -193,6 +200,11 @@ class AutoAssigner: "opencode-go": ["coding", "research", "fast"], "openai-codex": ["coding", "reasoning"], "antigravity": ["coding", "reasoning", "research", "fast"], + "local": ["reviewer", "coding", "reasoning", "fast", "research"], + "local-llm": ["reviewer", "coding", "reasoning", "fast", "research"], + "llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"], + "ollama": ["reviewer", "coding", "reasoning", "fast", "research"], + "vllm": ["reviewer", "coding", "reasoning", "fast", "research"], } capabilities = capabilities_map.get(provider, []) @@ -354,6 +366,11 @@ class AutoAssigner: "anthropic": "Claude", "grok": "Grok", "xai": "Grok", + "local": "Local LLM", + "local-llm": "Local LLM", + "llama.cpp": "Local LLM (llama.cpp)", + "ollama": "Ollama", + "vllm": "vLLM", } provider_label = prov_labels.get(pcfg.provider.lower(), pcfg.provider) diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 08a838c..54c9aad 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -146,7 +146,7 @@ class ModelDiscoveryService: ) -> None: """Discover models for all 5 providers concurrently in background.""" def _worker(): - providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"] + providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok", "local"] results: Dict[str, Optional[List[str]]] = {} threads = [] @@ -289,4 +289,49 @@ class ModelDiscoveryService: logger.debug("OpenCode model query failed on %s: %s", pid, exc) return None + 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: + continue + base_url = str(base_url).strip().rstrip("/") + if not base_url.startswith(("http://", "https://")): + base_url = f"http://{base_url}" + + api_key = auth.get("api_key") or os.environ.get("LOCAL_LLM_API_KEY") + headers = { + "Accept": "application/json", + "User-Agent": "hermes-hub/1.0", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + try: + 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 [] + if isinstance(items, list): + models = [ + str(m.get("id") or m.get("name") if isinstance(m, dict) else m) + for m in items + if m + ] + if models: + return sorted(models) + except Exception as exc: + logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc) + return None + return None diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 3f7704c..2beeb3a 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -316,6 +316,21 @@ 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"): + base_url = auth_data.get("base_url") + if base_url: + try: + from antigravity_provider.router.router_config import load_router_config, save_router_config + rcfg = load_router_config() + if profile_id in rcfg.profiles: + rcfg.profiles[profile_id].custom_base_url = str(base_url).strip() + if auth_data.get("models") and isinstance(auth_data["models"], list): + rcfg.profiles[profile_id].preferred_models = list(auth_data["models"]) + save_router_config(rcfg) + except Exception as e: + logger.warning("Failed to sync custom_base_url for local profile=%s: %s", profile_id, e) + from antigravity_provider.router.event_bus import ( EVENT_ACCOUNT_ADDED, EVENT_ACCOUNT_AUTH_CHANGED, @@ -417,6 +432,12 @@ class ProfileAuthManager: if val: return {"provider": "opencode-go", "profile_id": profile_id, "api_key": val} + elif provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + env_var = f"LOCAL_LLM_URL_{profile_id.upper().replace('-', '_')}" + val = os.environ.get(env_var) or os.environ.get("LOCAL_LLM_BASE_URL") + if val: + return {"provider": provider, "profile_id": profile_id, "base_url": val} + return None @classmethod @@ -555,6 +576,55 @@ class ProfileAuthManager: return True, masked, ["opencode-go-3"] return False, None, [] + @classmethod + def verify_local_endpoint( + cls, base_url: str, api_key: Optional[str] = None + ) -> Tuple[bool, Optional[str], List[str], Optional[str]]: + """Verify local OpenAI-compatible endpoint ({base_url}/models) and return (valid, display_name, models, error_msg).""" + url_str = (base_url or "").strip().rstrip("/") + if not url_str: + return False, None, [], "URL сервера не указан" + if not url_str.startswith(("http://", "https://")): + url_str = f"http://{url_str}" + + headers = { + "Accept": "application/json", + "User-Agent": "hermes-hub/1.0", + } + if api_key and api_key.strip(): + headers["Authorization"] = f"Bearer {api_key.strip()}" + + try: + req = urllib.request.Request( + f"{url_str}/models", + headers=headers, + method="GET", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + if resp.status in (200, 204): + 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)) + return True, f"Local Server ({url_str})", sorted(models) if models else ["default"], None + return False, None, [], f"Сервер вернул HTTP статус {resp.status}" + except urllib.error.HTTPError as http_err: + raw_err = http_err.read().decode("utf-8", errors="replace") + try: + err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) + except Exception: + err_msg = raw_err + return False, None, [], f"HTTP {http_err.code}: {err_msg}" + except urllib.error.URLError as url_err: + reason = str(url_err.reason) + return False, None, [], f"Не удалось подключиться к серверу ({reason})" + except Exception as exc: + return False, None, [], f"Ошибка подключения: {exc}" + @classmethod def get_profile_status(cls, provider: str, profile_id: str) -> Dict[str, Any]: """Check status and metadata for a profile.""" @@ -745,6 +815,31 @@ class ProfileAuthManager: "error": None, } + elif provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + base_url = auth_data.get("base_url") + if not base_url: + try: + from antigravity_provider.router.router_config import load_router_config + rcfg = load_router_config() + pcfg = rcfg.get_profile(profile_id) + if pcfg and pcfg.custom_base_url: + base_url = pcfg.custom_base_url + except Exception: + pass + if not base_url: + base_url = os.environ.get("LOCAL_LLM_BASE_URL") + key = auth_data.get("api_key", "") + is_auth = bool(base_url) + masked_acc = f"{base_url} [API Key]" if (base_url and key) else (str(base_url) if base_url else "Not configured") + return { + "authenticated": is_auth, + "provider": provider, + "profile_id": profile_id, + "account_id_masked": masked_acc, + "status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED", + "error": None if is_auth else "URL сервера не настроен", + } + return { "authenticated": False, "provider": provider, diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index 16c39cc..b3f78fb 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -123,6 +123,11 @@ class AccountQuotaService: auth_data = ProfileAuthManager.load_profile_auth(provider, profile_id) if not auth_data: + if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + snap = self._collect_local_quota(profile_id, {}) + with self._cache_lock: + self._snapshots[key] = snap + return snap snap = QuotaSnapshot( account_id=profile_id, provider=provider, @@ -135,7 +140,9 @@ class AccountQuotaService: return snap try: - if provider == "antigravity": + if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + snap = self._collect_local_quota(profile_id, auth_data) + elif provider == "antigravity": snap = self._collect_antigravity_quota(profile_id, auth_data) elif provider in ("openai-codex", "codex"): snap = self._collect_codex_quota(profile_id, auth_data) @@ -309,6 +316,8 @@ class AccountQuotaService: plan_code = auth_data.get("plan_type", "MAX" if "token" in auth_data else "API Key") elif provider == "grok": plan_code = auth_data.get("plan_type", "Grok Pro" if "token" in auth_data else "API Key") + elif provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + plan_code = auth_data.get("plan_type", "LOCAL") plan = SubscriptionPlan.create(plan_code, source="provider_auth") @@ -795,9 +804,48 @@ class AccountQuotaService: unavailable_reason="Grok не предоставляет остаток через публичный API", ) + def _collect_local_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: + """Create unlimited quota snapshot for Local LLM servers.""" + now = _utc_now() + b_unlimited = QuotaBucket( + id="local.unlimited", + display_name="Локальный сервер", + status="unlimited", + period="unlimited", + used_percent=0.0, + remaining_percent=100.0, + ) + return QuotaSnapshot( + account_id=profile_id, + provider="local", + buckets=[b_unlimited], + fetched_at=now, + source="local_provider", + unavailable_reason="Без ограничений (локальная модель, квоты отсутствуют)", + is_loading=False, + ) + def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot: """Truthful offline baseline with provider-specific independent limit pools.""" now = _utc_now() + if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + b_unlimited = QuotaBucket( + id="local.unlimited", + display_name="Локальный сервер", + status="unlimited", + period="unlimited", + used_percent=0.0, + remaining_percent=100.0, + ) + return QuotaSnapshot( + account_id=profile_id, + provider=provider, + buckets=[b_unlimited], + fetched_at=now, + source="local_provider", + unavailable_reason="Без ограничений (локальная модель, квоты отсутствуют)", + is_loading=False, + ) bucket_specs = { "antigravity": [ ("antigravity.claude.5h", "Claude 5h", "claude", "5h"), diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 2efcb96..86db45a 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -251,6 +251,23 @@ def get_default_router_config() -> RouterConfig: preferred_models=["grok-3", "grok-3-mini"], max_concurrency=2, ), + # 6. Local LLM Pool (2 accounts) + "local-1": RouterProfileConfig( + profile_id="local-1", + provider="local", + account_id="local-acc-1", + capabilities=["reviewer", "coder-secondary", "reasoning", "coding"], + preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"], + max_concurrency=1, + ), + "local-2": RouterProfileConfig( + profile_id="local-2", + provider="local", + account_id="local-acc-2", + capabilities=["fast", "research", "coding"], + preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"], + max_concurrency=1, + ), } roles: dict[str, RolePolicy] = { diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index 013abe5..b636f68 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -152,6 +152,7 @@ class AddAccountWizard(HubModal): ("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE), ("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK), ("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC), + ("local", "Локальная модель (Local LLM / llama.cpp)", "Локальный сервер / Ollama / vLLM / llama.cpp", Theme.STATUS_HEALTHY), ] self.provider_var = ctk.StringVar(value=self.selected_provider) @@ -234,6 +235,8 @@ class AddAccountWizard(HubModal): self._build_grok_oauth_flow() else: self._build_api_key_flow() + elif self.selected_provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + self._build_local_llm_flow() else: self._build_api_key_flow() @@ -245,6 +248,7 @@ class AddAccountWizard(HubModal): "opencode-go": "OpenCode Go", "claude": "Claude", "grok": "Grok", + "local": "Local LLM", } card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED) card.pack(fill="x", pady=20) @@ -1312,6 +1316,187 @@ class AddAccountWizard(HubModal): if hasattr(self, "key_status_lbl"): self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}") + # ───────────────────────────────────────────────────────────── + # LOCAL LLM FLOW (llama.cpp / vLLM / Ollama / Local Server) + # ───────────────────────────────────────────────────────────── + + def _build_local_llm_flow(self): + disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot) + + ctk.CTkLabel( + self.body, + text=f"Подключение локального сервера к слоту: {self.target_slot} ({disp_name})", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + anchor="w", + ).pack(fill="x", pady=(0, 4)) + + info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) + info_card.pack(fill="x", pady=(0, 8)) + + ctk.CTkLabel( + info_card, + text="Подключение к локальному серверу (llama.cpp / vLLM / Ollama / Local LLM):", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + anchor="w", + ).pack(fill="x", padx=12, pady=(10, 4)) + + ctk.CTkLabel( + info_card, + text=( + "Укажите базовый URL OpenAI-совместимого эндпоинта (например, http://127.0.0.1:8081/v1 или http://localhost:8080/v1).\n" + "Если сервер защищен токеном, введите его в поле API Key." + ), + font=Theme.font_body(), + text_color=Theme.TEXT_SECONDARY, + justify="left", + anchor="w", + ).pack(fill="x", padx=12, pady=(0, 10)) + + # URL Input + ctk.CTkLabel( + self.body, + text="URL сервера (Base URL):", + font=Theme.font_body(), + text_color=Theme.TEXT_SECONDARY, + ).pack(anchor="w", pady=(0, 4)) + + url_row = ctk.CTkFrame(self.body, fg_color="transparent") + url_row.pack(fill="x", pady=(0, 8)) + + self.local_url_entry = HubEntry( + url_row, + placeholder_text="http://127.0.0.1:8081/v1", + font=Theme.font_mono(), + height=36, + fg_color=Theme.PRIMARY, + border_color=Theme.BORDER, + text_color=Theme.TEXT_PRIMARY, + ) + self.local_url_entry.insert(0, "http://127.0.0.1:8081/v1") + self.local_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) + + HubButton( + url_row, + text="📋 Вставить", + variant="secondary", + width=90, + height=36, + command=lambda: self._paste_into_entry(self.local_url_entry), + ).pack(side="right") + + # Optional API Key Input + ctk.CTkLabel( + self.body, + text="API Key (опционально):", + font=Theme.font_body(), + text_color=Theme.TEXT_SECONDARY, + ).pack(anchor="w", pady=(0, 4)) + + key_row = ctk.CTkFrame(self.body, fg_color="transparent") + key_row.pack(fill="x", pady=(0, 8)) + + self.local_key_entry = HubEntry( + key_row, + placeholder_text="Оставьте пустым, если ключ не требуется", + font=Theme.font_mono(), + show="*", + height=36, + fg_color=Theme.PRIMARY, + border_color=Theme.BORDER, + text_color=Theme.TEXT_PRIMARY, + ) + self.local_key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) + + HubButton( + key_row, + text="📋 Вставить", + variant="secondary", + width=90, + height=36, + command=lambda: self._paste_into_entry(self.local_key_entry), + ).pack(side="right") + + # Test button and Status label + test_row = ctk.CTkFrame(self.body, fg_color="transparent") + test_row.pack(fill="x", pady=(0, 6)) + + self.local_test_btn = HubButton( + test_row, + text="🔍 Проверить подключение", + variant="secondary", + height=32, + command=self._test_local_connection, + ) + self.local_test_btn.pack(side="left") + + self.local_status_lbl = ctk.CTkLabel( + self.body, + text="", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + anchor="w", + ) + self.local_status_lbl.pack(fill="x", pady=4) + + def _save_local(): + url = self.local_url_entry.get().strip() + key = self.local_key_entry.get().strip() + if not url: + self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR) + return + + ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None) + auth_data = { + "provider": "local", + "profile_id": self.target_slot, + "base_url": url, + "api_key": key if key else None, + "models": models if ok else [], + "created_at": time.time(), + } + ProfileAuthManager.save_profile_auth("local", self.target_slot, auth_data) + + self.is_verified = ok + self.discovered_identity = url + self.discovered_plan = "Локальная модель (без квот)" + self.discovered_models = models if ok else [] + self._show_step_3_validation() + + self._build_step_2_footer(next_cmd=_save_local) + + def _test_local_connection(self): + url = self.local_url_entry.get().strip() + key = self.local_key_entry.get().strip() + if not url: + self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR) + return + + self.local_status_lbl.configure(text="⏳ Проверка подключения к серверу...", text_color=Theme.TEXT_SECONDARY) + + def _worker(): + ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None) + if ok: + m_str = ", ".join(models[:3]) + (f" (+{len(models)-3})" if len(models) > 3 else "") + self.after( + 0, + lambda: self.local_status_lbl.configure( + text=f"✓ Подключение успешно! Доступные модели: {m_str}", + text_color=Theme.STATUS_HEALTHY, + ), + ) + else: + self.after( + 0, + lambda: self.local_status_lbl.configure( + text=f"❌ Ошибка подключения: {err}", + text_color=Theme.STATUS_ERROR, + ), + ) + + threading.Thread(target=_worker, daemon=True).start() + # ═══════════════════════════════════════════════════════════════ # STEP 3: Validation & Identity # ═══════════════════════════════════════════════════════════════ diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 55214f3..b745e84 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -372,6 +372,11 @@ function renderAccountsView() { 'opencode-go': 'OpenCode Go', claude: 'Claude (Anthropic)', grok: 'Grok (xAI)', + local: 'Local LLM', + 'local-llm': 'Local LLM', + 'llama.cpp': 'Local LLM (llama.cpp)', + ollama: 'Ollama', + vllm: 'vLLM', }; const profilesByProv = currentSnapshot.profiles_by_provider || {}; @@ -1447,6 +1452,13 @@ function showWizardStep1() {
OAuth редирект (требует браузер или перенос профиля)
+ `; elements.modalFooter.innerHTML = ` @@ -1515,6 +1527,20 @@ function showWizardStep2(providerId) { `; + } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') { + bodyHtml = ` +
+ Шаг 2 из 3: Настройка локального сервера (Local LLM) +
+
+ + +
+
+ + +
+ `; } else { bodyHtml = `
@@ -1534,10 +1560,22 @@ function showWizardStep2(providerId) { elements.modalFooter.innerHTML = ` - + `; } +function proceedToWizardStep3(providerId) { + const baseInput = document.getElementById('wiz-base-url-input'); + if (baseInput) { + window._wiz_base_url = baseInput.value.trim(); + } + const tokenInput = document.getElementById('wiz-token-input'); + if (tokenInput) { + window._wiz_token = tokenInput.value.trim(); + } + showWizardStep3(providerId); +} + function showWizardStep3(providerId) { elements.modalBody.innerHTML = ` @@ -1573,10 +1611,18 @@ async function finishAddAccount(providerId) { feedbackArea.innerHTML = ''; } - const res = await executeAction('add_account', { + const payload = { provider: providerId, target_role: targetRole, - }); + }; + if (window._wiz_base_url) { + payload.base_url = window._wiz_base_url; + } + if (window._wiz_token) { + payload.token = window._wiz_token; + } + + const res = await executeAction('add_account', payload); if (res.ok) { showToast('Аккаунт успешно добавлен в маршрутизацию', 'success'); diff --git a/tests/test_a9_migration_quotas_models.py b/tests/test_a9_migration_quotas_models.py index a42910e..178bc11 100644 --- a/tests/test_a9_migration_quotas_models.py +++ b/tests/test_a9_migration_quotas_models.py @@ -86,7 +86,7 @@ class TestA9ConfigMigration(unittest.TestCase): self.assertIn("claude-orch", migrated_cfg.profiles) self.assertIn("claude-worker-1", migrated_cfg.profiles) self.assertIn("claude-worker-2", migrated_cfg.profiles) - self.assertEqual(len(migrated_cfg.profiles), 22) + self.assertEqual(len(migrated_cfg.profiles), 24) # 5. Verify existing 10 antigravity profiles are 100% untouched for pid in ["ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"]: @@ -106,7 +106,7 @@ class TestA9ConfigMigration(unittest.TestCase): # 7. Verify Idempotence: subsequent loads do not create extra backups backup_count_before = len(backups) reload_cfg = load_router_config(self.config_path) - self.assertEqual(len(reload_cfg.profiles), 22) + self.assertEqual(len(reload_cfg.profiles), 24) backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*"))) self.assertEqual(backup_count_before, backup_count_after) diff --git a/tests/test_local_llm_provider_a25.py b/tests/test_local_llm_provider_a25.py new file mode 100644 index 0000000..1151d5d --- /dev/null +++ b/tests/test_local_llm_provider_a25.py @@ -0,0 +1,366 @@ +"""Unit and integration tests for Task A25: Local LLM Provider Integration. + +Tests: +1. LocalLLMAdapter.invoke with mock HTTP server / urllib. +2. Error classification and instant failover on network / auth / rate limit errors. +3. discover_models and ModelDiscoveryService for 'local'. +4. find_free_slot("local") and config migration for local-1 / local-2. +5. Quota snapshot state "Без ограничений" (is_loading=False, status=unlimited). +6. Configurable custom_base_url and absence of hardcoded unconfigurable IP/ports. +7. ProfileAuthManager local endpoint verification and profile status. +""" +from __future__ import annotations + +import io +import json +import os +import shutil +import sys +import tempfile +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.adapters import get_adapter +from antigravity_provider.router.adapters.base_adapter import ErrorCategory +from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.model_discovery_service import ModelDiscoveryService +from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.quota_collector import AccountQuotaService +from antigravity_provider.router.router_config import ( + RouterConfig, + RouterProfileConfig, + get_default_router_config, + load_router_config, + save_router_config, +) + + +class TestLocalLLMAdapterInvoke: + """Test LocalLLMAdapter invocation and parameters.""" + + def test_adapter_registration(self): + """Adapter must be registered for local, local-llm, llama.cpp, ollama, vllm.""" + for key in ["local", "local-llm", "llama.cpp", "ollama", "vllm"]: + adapter = get_adapter(key) + assert isinstance(adapter, LocalLLMAdapter) + + def test_invoke_success_without_api_key(self): + """Invoke should perform POST to {base_url}/chat/completions without Authorization header if no key.""" + adapter = LocalLLMAdapter() + profile = RouterProfileConfig( + profile_id="local-1", + provider="local", + custom_base_url="http://127.0.0.1:8081/v1", + preferred_models=["Qwen3.8-27B-Q4_K_M.gguf"], + ) + request = { + "messages": [{"role": "user", "content": "Hello"}], + "temperature": 0.5, + } + + mock_resp_data = { + "id": "chatcmpl-123", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hi there!"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8") + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: + resp = adapter.invoke(profile, request) + + assert resp == mock_resp_data + mock_urlopen.assert_called_once() + req_arg = mock_urlopen.call_args[0][0] + assert req_arg.get_full_url() == "http://127.0.0.1:8081/v1/chat/completions" + assert req_arg.get_method() == "POST" + # Verify no Authorization header sent + assert "Authorization" not in req_arg.headers + payload = json.loads(req_arg.data.decode("utf-8")) + assert payload["model"] == "Qwen3.8-27B-Q4_K_M.gguf" + assert payload["messages"] == [{"role": "user", "content": "Hello"}] + assert payload["temperature"] == 0.5 + + def test_invoke_with_api_key_and_custom_base_url(self): + """Invoke should include Bearer token and use custom base url when provided.""" + adapter = LocalLLMAdapter() + profile = RouterProfileConfig( + profile_id="local-2", + provider="local", + custom_base_url="http://192.168.1.50:11434/v1", + preferred_models=["llama3:8b"], + auth_config={"api_key": "secret-token-123"}, + ) + request = { + "model": "llama3:8b", + "messages": [{"role": "user", "content": "Test"}], + "tools": [{"type": "function", "function": {"name": "search"}}], + } + + mock_resp_data = {"id": "chatcmpl-456", "choices": []} + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8") + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: + resp = adapter.invoke(profile, request) + assert resp == mock_resp_data + + req_arg = mock_urlopen.call_args[0][0] + assert req_arg.get_full_url() == "http://192.168.1.50:11434/v1/chat/completions" + assert req_arg.headers.get("Authorization") == "Bearer secret-token-123" + payload = json.loads(req_arg.data.decode("utf-8")) + assert payload["tools"] == [{"type": "function", "function": {"name": "search"}}] + + +class TestLocalLLMErrorClassification: + """Test error classification for instant failover and auth errors.""" + + def test_classify_connection_refused_as_transient(self): + adapter = LocalLLMAdapter() + exc = urllib.error.URLError("Connection refused [WinError 10061]") + classification = adapter.classify_error(exc) + assert classification.category == ErrorCategory.TRANSIENT + assert classification.retry_delay_seconds <= 5 + + def test_classify_timeout_as_transient(self): + adapter = LocalLLMAdapter() + exc = TimeoutError("The read operation timed out") + classification = adapter.classify_error(exc) + assert classification.category == ErrorCategory.TRANSIENT + assert classification.retry_delay_seconds <= 5 + + def test_classify_http_502_503_as_transient(self): + adapter = LocalLLMAdapter() + exc = RuntimeError("Local LLM API Error (502): Bad Gateway") + classification = adapter.classify_error(exc) + assert classification.category == ErrorCategory.TRANSIENT + + def test_classify_http_401_403_as_auth_required(self): + adapter = LocalLLMAdapter() + exc = RuntimeError("Local LLM API Error (401): Unauthorized access") + classification = adapter.classify_error(exc) + assert classification.category == ErrorCategory.AUTH_REQUIRED + + def test_classify_http_429_as_rate_limited(self): + adapter = LocalLLMAdapter() + exc = RuntimeError("Local LLM API Error (429): Too Many Requests") + classification = adapter.classify_error(exc) + assert classification.category == ErrorCategory.RATE_LIMITED + + +class TestLocalLLMModelDiscovery: + """Test discover_models and ModelDiscoveryService for local provider.""" + + def test_adapter_discover_models_success(self): + adapter = LocalLLMAdapter() + profile = RouterProfileConfig( + profile_id="local-1", + provider="local", + custom_base_url="http://127.0.0.1:8081/v1", + ) + + mock_models_resp = { + "data": [ + {"id": "Qwen3.8-27B-Q4_K_M.gguf", "object": "model"}, + {"id": "deepseek-coder-6.7b.gguf", "object": "model"}, + ] + } + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(mock_models_resp).encode("utf-8") + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response): + models = adapter.discover_models(profile) + assert "Qwen3.8-27B-Q4_K_M.gguf" in models + assert "deepseek-coder-6.7b.gguf" in models + + def test_adapter_health_check(self): + adapter = LocalLLMAdapter() + profile = RouterProfileConfig( + profile_id="local-1", + provider="local", + custom_base_url="http://127.0.0.1:8081/v1", + ) + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response): + assert adapter.health_check(profile) is True + + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): + assert adapter.health_check(profile) is False + + def test_model_discovery_service_local_probe(self): + tmp_dir = tempfile.mkdtemp() + try: + cache_file = Path(tmp_dir) / "models_cache.json" + service = ModelDiscoveryService(cache_path=cache_file) + + mock_models_resp = { + "models": [ + {"name": "local-qwen-27b"}, + {"name": "local-mistral-7b"}, + ] + } + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(mock_models_resp).encode("utf-8") + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response): + models = service.discover_models_sync("local") + assert models is not None + assert "local-qwen-27b" in models + assert "local-mistral-7b" in models + assert service.get_models("local") == sorted(["local-qwen-27b", "local-mistral-7b"]) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +class TestLocalLLMConfigAndAutoAssigner: + """Test default configuration, migration, and slot auto-assignment.""" + + def test_default_config_contains_local_slots(self): + cfg = get_default_router_config() + assert "local-1" in cfg.profiles + assert "local-2" in cfg.profiles + + p1 = cfg.profiles["local-1"] + assert p1.provider == "local" + assert "reviewer" in p1.capabilities + assert "coding" in p1.capabilities + assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models + assert p1.max_concurrency == 1 + + p2 = cfg.profiles["local-2"] + assert p2.provider == "local" + assert "fast" in p2.capabilities + assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models + assert p2.max_concurrency == 1 + + def test_config_migration_adds_local_slots(self): + tmp_dir = tempfile.mkdtemp() + try: + config_path = Path(tmp_dir) / "router_profiles.yaml" + legacy_profiles = { + "codex-orch": RouterProfileConfig( + profile_id="codex-orch", + provider="openai-codex", + ) + } + legacy_cfg = RouterConfig( + enabled=True, + default_role="orchestrator", + roles=get_default_router_config().roles, + profiles=legacy_profiles, + ) + save_router_config(legacy_cfg, config_path) + + migrated = load_router_config(config_path) + assert "local-1" in migrated.profiles + assert "local-2" in migrated.profiles + assert migrated.profiles["local-1"].provider == "local" + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + def test_auto_assigner_find_free_slot_local(self): + slot = AutoAssigner.find_free_slot("local") + assert slot in ("local-1", "local-2") + + +class TestLocalLLMQuotaHonesty: + """Test honest display of 'Без ограничений' for Local LLM quota.""" + + def test_local_quota_snapshot_structure(self): + snap = AccountQuotaService.get().fetch_account_quota("local", "local-1") + assert snap is not None + assert snap.is_loading is False + assert snap.source in ("local_provider", "baseline") + assert "Без ограничений" in (snap.unavailable_reason or "") + + assert len(snap.buckets) == 1 + bucket = snap.buckets[0] + assert bucket.id == "local.unlimited" + assert bucket.status == "unlimited" + assert bucket.period == "unlimited" + assert bucket.used_percent == 0.0 + assert bucket.remaining_percent == 100.0 + assert bucket.formatted_remaining() == "Без ограничений" + + +class TestProfileManagerLocalVerification: + """Test ProfileAuthManager local endpoint verification and base_url synchronization.""" + + def test_verify_local_endpoint_success(self): + mock_data = { + "data": [ + {"id": "qwen3.8-27b"}, + {"id": "deepseek-r1-distill"}, + ] + } + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = json.dumps(mock_data).encode("utf-8") + mock_response.__enter__.return_value = mock_response + + with patch("urllib.request.urlopen", return_value=mock_response): + ok, dname, models, err = ProfileAuthManager.verify_local_endpoint( + "http://127.0.0.1:8081/v1", api_key="my-key" + ) + assert ok is True + assert dname is not None and "127.0.0.1:8081" in dname + assert "qwen3.8-27b" in models + assert "deepseek-r1-distill" in models + assert err is None + + def test_verify_local_endpoint_connection_refused(self): + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): + ok, dname, models, err = ProfileAuthManager.verify_local_endpoint("http://127.0.0.1:9999/v1") + assert ok is False + assert dname is None + assert models == [] + assert err is not None + assert "Connection refused" in err + + def test_save_profile_auth_syncs_custom_base_url(self): + tmp_dir = tempfile.mkdtemp() + try: + config_path = Path(tmp_dir) / "router_profiles.yaml" + save_router_config(get_default_router_config(), config_path) + + with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(config_path)}): + auth_data = { + "provider": "local", + "profile_id": "local-1", + "base_url": "http://192.168.1.120:8000/v1", + "models": ["my-custom-model"], + } + ProfileAuthManager.save_profile_auth("local", "local-1", auth_data) + + # Verify custom_base_url synced in router_profiles.yaml + reloaded = load_router_config(config_path) + p1 = reloaded.get_profile("local-1") + assert p1 is not None + assert p1.custom_base_url == "http://192.168.1.120:8000/v1" + assert p1.preferred_models == ["my-custom-model"] + + status = ProfileAuthManager.get_profile_status("local", "local-1") + assert status["authenticated"] is True + assert "192.168.1.120:8000" in status["account_id_masked"] + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/tests/test_multi_provider_router.py b/tests/test_multi_provider_router.py index 138adb7..373c560 100644 --- a/tests/test_multi_provider_router.py +++ b/tests/test_multi_provider_router.py @@ -48,9 +48,9 @@ from antigravity_provider.router.cli_commands import ( class TestRouterConfig: """Test configuration schema, profile loading, and role definitions.""" - def test_default_config_has_22_profiles(self): + def test_default_config_has_24_profiles(self): config = get_default_router_config() - assert len(config.profiles) == 22 + assert len(config.profiles) == 24 # 3 Codex assert "codex-orch" in config.profiles assert "codex-worker-1" in config.profiles diff --git a/tests/test_unified_health.py b/tests/test_unified_health.py index 2f44698..d3c9b9d 100644 --- a/tests/test_unified_health.py +++ b/tests/test_unified_health.py @@ -45,7 +45,7 @@ def test_profile_view_model_mapping(): assert isinstance(p, ProfileViewModel) assert p.profile_id assert p.display_name - assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok") + assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local") 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"