Merge A24 (маршрутизация как центр управления) и A25 (локальная модель)

Обе работы приняты, проверено исполнением.

A24: ровно семь разделов, renderTeam и renderProviders удалены, кнопки
«Изменить цепочку» нет, перетаскивание блоков есть. Перестановка цепочки
проверена вживую: сохраняется в router_profiles.yaml и откатывается.

A25: провайдер local подключён к настоящему серверу владельца через
SSH-туннель к 127.0.0.1:8081. health_check проходит, /v1/models отдаёт
модель, реальный вызов возвращает «ОК» за 3.1 с. Профили local-1 и
local-2 добавлены. Квота отдаётся отдельным состоянием
(source=local_provider, «Без ограничений»), а не как отсутствие данных.
Адрес сервера нигде не зашит.

Разрешение конфликта в action_handler: A25 внёс edit_route и assign_role
в список «просто навигация», где они возвращают заглушку. В A24 это
работающие обработчики — сохранение цепочки и назначение роли. Приняв
версию A25 целиком, мы бы молча сломали перестановку блоков. Оставлены
оба: локальный провайдер в add_account и рабочие обработчики ниже.

Исправлено при слиянии:

1. Адаптер отдавал пустой ответ как успех. У сервера владельца
   --reasoning on --reasoning-budget 4096: при скромном max_tokens весь
   бюджет уходит на рассуждения, llama.cpp возвращает 200, заполняет
   reasoning_content и оставляет content пустым. Проверено на живой
   модели: max_tokens=40 — ответа нет, 200 — приходит «ОК». Роутер
   засчитал бы такой вызов, а пользователь не получил бы ничего.
   Теперь это явный отказ с объяснением, и срабатывает переключение.
   Проверка вынесена из блока перехвата: иначе оборачивалась в
   «Transport Error», хотя транспорт отработал штатно.

2. Заглушка в тесте A25 возвращала "choices": [] — такого настоящий
   сервер не отдаёт. Приведена к реальному виду.

Тесты: 404 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 14:33:16 +07:00
commit 9a2c341f15
16 changed files with 1176 additions and 14 deletions

View file

@ -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 ""

View file

@ -238,9 +238,32 @@ 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', 'open_routing']:
# Подключение аккаунта: для локального сервера это не навигация, а
# настоящее сохранение профиля с адресом.
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': 'Навигация'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
# сохранение цепочки и назначение роли — обработчики ниже. Проглотив их
# здесь, мы бы молча сломали перестановку блоков в маршрутизации.
if action in ['oauth', 'account_details', 'agent_settings', 'open_routing']:
return {'ok': True, 'message': 'Навигация'}
if action in ['save_chain', 'reorder_chain', 'edit_route']:

View file

@ -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(),
}

View file

@ -0,0 +1,245 @@
"""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:
data = 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
# Проверка ПОСЛЕ блока перехвата: иначе отказ по пустому ответу
# оборачивался в «Transport Error», хотя транспорт отработал штатно.
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех.
Сервер владельца поднят с ``--reasoning on --reasoning-budget 4096``.
При скромном ``max_tokens`` весь бюджет уходит на рассуждения: модель
возвращает 200, заполняет ``reasoning_content`` и оставляет ``content``
пустым. Проверено на живой модели: с max_tokens=40 ответа нет, с 200
приходит «ОК».
Если отдать такой ответ дальше как успешный, роутер засчитает вызов, а
пользователь не получит ничего и не узнает почему. Поэтому отказываем
явно тогда сработает переключение на следующий профиль.
"""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("Local LLM вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
if message.get("reasoning_content"):
raise RuntimeError(
"Local LLM израсходовал лимит токенов на рассуждения и не выдал ответ "
f"(finish_reason={finish}). Увеличьте max_tokens или уменьшите "
"--reasoning-budget на сервере."
)
raise RuntimeError(f"Local LLM вернул пустой ответ (finish_reason={finish})")
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)

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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"),

View file

@ -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] = {

View file

@ -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
# ═══════════════════════════════════════════════════════════════

View file

@ -385,6 +385,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 || {};
@ -1543,6 +1548,13 @@ function showWizardStep1() {
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (требует браузер или перенос профиля)</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('local')">
<span style="font-size:18px; color:var(--status-healthy, #22c55e);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Локальная модель (Local LLM)</div>
<div style="font-size:11px; color:var(--text-muted);">llama.cpp / Ollama / vLLM (OpenAI-совместимый сервер)</div>
</div>
</button>
</div>
`;
elements.modalFooter.innerHTML = `
@ -1611,6 +1623,20 @@ function showWizardStep2(providerId) {
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Вставьте токен или нажмите Далее...">
</div>
`;
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Настройка локального сервера (Local LLM)
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:8081/v1" value="http://127.0.0.1:8081/v1">
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key (опционально):</label>
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Оставьте пустым, если ключ не требуется">
</div>
`;
} else {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
@ -1630,10 +1656,22 @@ function showWizardStep2(providerId) {
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="showWizardStep1()"> Назад</button>
<button class="btn btn-primary" onclick="showWizardStep3('${providerId}')">Продолжить </button>
<button class="btn btn-primary" onclick="proceedToWizardStep3('${providerId}')">Продолжить </button>
`;
}
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 = `
<div id="modal-feedback-area"></div>
@ -1669,10 +1707,18 @@ async function finishAddAccount(providerId) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
}
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');

View file

@ -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)

View file

@ -0,0 +1,51 @@
"""Пустой ответ локальной модели — отказ, а не успех.
Сервер владельца поднят с ``--reasoning on --reasoning-budget 4096``. При
скромном ``max_tokens`` весь бюджет уходит на рассуждения: llama.cpp
возвращает 200, заполняет ``reasoning_content`` и оставляет ``content``
пустым. Проверено на живой модели через SSH-туннель: с max_tokens=40
ответа нет, с 200 приходит «ОК».
Если отдать такой ответ дальше как успешный, роутер засчитает вызов, а
пользователь не получит ничего и не узнает почему.
"""
from __future__ import annotations
import pytest
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
def test_answer_with_content_passes():
data = {
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "ОК"}, "finish_reason": "stop"}
]
}
LocalLLMAdapter._reject_empty_answer(data) # не должно бросать
def test_reasoning_ate_the_budget_is_rejected():
data = {
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "", "reasoning_content": "долгие раздумья"},
"finish_reason": "length",
}
]
}
with pytest.raises(RuntimeError, match="рассужд"):
LocalLLMAdapter._reject_empty_answer(data)
def test_empty_choices_is_rejected():
with pytest.raises(RuntimeError, match="choices"):
LocalLLMAdapter._reject_empty_answer({"choices": []})
def test_blank_content_without_reasoning_is_rejected():
data = {"choices": [{"index": 0, "message": {"content": " "}, "finish_reason": "stop"}]}
with pytest.raises(RuntimeError, match="пустой"):
LocalLLMAdapter._reject_empty_answer(data)

View file

@ -0,0 +1,378 @@
"""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"}}],
}
# Заглушка должна быть похожа на настоящий ответ: сервер всегда
# возвращает хотя бы один choice с текстом. Пустой choices означает
# отказ, и адаптер обязан его отвергнуть, а не выдать за успех.
mock_resp_data = {
"id": "chatcmpl-456",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "OK"},
"finish_reason": "stop",
}
],
}
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)

View file

@ -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

View file

@ -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"