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() {