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:
commit
9a2c341f15
16 changed files with 1176 additions and 14 deletions
|
|
@ -78,6 +78,8 @@ class SubscriptionPlan:
|
||||||
"TIER2": "TIER 2",
|
"TIER2": "TIER 2",
|
||||||
"SUPERGROK": "SUPERGROK",
|
"SUPERGROK": "SUPERGROK",
|
||||||
"GROK PRO": "GROK PRO",
|
"GROK PRO": "GROK PRO",
|
||||||
|
"LOCAL": "LOCAL",
|
||||||
|
"LOCAL MODEL": "LOCAL MODEL",
|
||||||
}
|
}
|
||||||
disp = display_map.get(cleaned, display_map.get(code_upper, cleaned))
|
disp = display_map.get(cleaned, display_map.get(code_upper, cleaned))
|
||||||
return cls(
|
return cls(
|
||||||
|
|
@ -172,7 +174,9 @@ class QuotaBucket:
|
||||||
self.used_percent = max(0.0, min(100.0, 100.0 - float(self.remaining_percent)))
|
self.used_percent = max(0.0, min(100.0, 100.0 - float(self.remaining_percent)))
|
||||||
|
|
||||||
# Auto-determine status
|
# 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):
|
if self.remaining_percent <= 0.0 or (self.used_percent is not None and self.used_percent >= 100.0):
|
||||||
self.status = "exhausted"
|
self.status = "exhausted"
|
||||||
elif self.remaining_percent < 15.0:
|
elif self.remaining_percent < 15.0:
|
||||||
|
|
@ -193,6 +197,8 @@ class QuotaBucket:
|
||||||
|
|
||||||
def formatted_remaining(self) -> str:
|
def formatted_remaining(self) -> str:
|
||||||
"""User-facing unambiguous string."""
|
"""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:
|
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)
|
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 ""
|
rem_pct_str = f" · Осталось {self.remaining_percent:.0f}%" if self.remaining_percent is not None else ""
|
||||||
|
|
|
||||||
|
|
@ -238,9 +238,32 @@ class ActionExecutor:
|
||||||
"""
|
"""
|
||||||
pid = data.get('profile_id', '')
|
pid = data.get('profile_id', '')
|
||||||
prov = data.get('provider', '')
|
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': 'Навигация'}
|
return {'ok': True, 'message': 'Навигация'}
|
||||||
|
|
||||||
if action in ['save_chain', 'reorder_chain', 'edit_route']:
|
if action in ['save_chain', 'reorder_chain', 'edit_route']:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from .claude_adapter import ClaudeAdapter
|
||||||
from .codex_adapter import CodexAdapter
|
from .codex_adapter import CodexAdapter
|
||||||
from .deepseek_adapter import DeepSeekResponsesAdapter
|
from .deepseek_adapter import DeepSeekResponsesAdapter
|
||||||
from .grok_adapter import GrokAdapter
|
from .grok_adapter import GrokAdapter
|
||||||
|
from .local_adapter import LocalLLMAdapter
|
||||||
from .opencode_adapter import OpenCodeGoAdapter
|
from .opencode_adapter import OpenCodeGoAdapter
|
||||||
|
|
||||||
_ADAPTERS: dict[str, BaseProviderAdapter] = {
|
_ADAPTERS: dict[str, BaseProviderAdapter] = {
|
||||||
|
|
@ -24,6 +25,11 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = {
|
||||||
"xai": GrokAdapter(),
|
"xai": GrokAdapter(),
|
||||||
"xai-oauth": GrokAdapter(),
|
"xai-oauth": GrokAdapter(),
|
||||||
"deepseek": DeepSeekResponsesAdapter(),
|
"deepseek": DeepSeekResponsesAdapter(),
|
||||||
|
"local": LocalLLMAdapter(),
|
||||||
|
"local-llm": LocalLLMAdapter(),
|
||||||
|
"llama.cpp": LocalLLMAdapter(),
|
||||||
|
"ollama": LocalLLMAdapter(),
|
||||||
|
"vllm": LocalLLMAdapter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
245
src/antigravity_provider/router/adapters/local_adapter.py
Normal file
245
src/antigravity_provider/router/adapters/local_adapter.py
Normal 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)
|
||||||
|
|
@ -57,6 +57,8 @@ DEFAULT_SLOT_ROLES = {
|
||||||
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
||||||
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
||||||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||||
|
"local-1": ("Локальный сервер 1", "coder", "primary"),
|
||||||
|
"local-2": ("Локальный сервер 2", "fast", "primary"),
|
||||||
"ag-spare-1": ("Резерв 1", "spare", "spare"),
|
"ag-spare-1": ("Резерв 1", "spare", "spare"),
|
||||||
"ag-spare-2": ("Резерв 2", "spare", "spare"),
|
"ag-spare-2": ("Резерв 2", "spare", "spare"),
|
||||||
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
|
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
|
||||||
|
|
@ -148,6 +150,11 @@ class AutoAssigner:
|
||||||
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
|
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
|
||||||
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
||||||
"grok": ["grok-orch", "grok-worker-1", "grok-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, []))
|
candidates = list(provider_slots.get(provider, []))
|
||||||
|
|
@ -193,6 +200,11 @@ class AutoAssigner:
|
||||||
"opencode-go": ["coding", "research", "fast"],
|
"opencode-go": ["coding", "research", "fast"],
|
||||||
"openai-codex": ["coding", "reasoning"],
|
"openai-codex": ["coding", "reasoning"],
|
||||||
"antigravity": ["coding", "reasoning", "research", "fast"],
|
"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, [])
|
capabilities = capabilities_map.get(provider, [])
|
||||||
|
|
||||||
|
|
@ -354,6 +366,11 @@ class AutoAssigner:
|
||||||
"anthropic": "Claude",
|
"anthropic": "Claude",
|
||||||
"grok": "Grok",
|
"grok": "Grok",
|
||||||
"xai": "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)
|
provider_label = prov_labels.get(pcfg.provider.lower(), pcfg.provider)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ class ModelDiscoveryService:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Discover models for all 5 providers concurrently in background."""
|
"""Discover models for all 5 providers concurrently in background."""
|
||||||
def _worker():
|
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]]] = {}
|
results: Dict[str, Optional[List[str]]] = {}
|
||||||
threads = []
|
threads = []
|
||||||
|
|
||||||
|
|
@ -289,4 +289,49 @@ class ModelDiscoveryService:
|
||||||
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
|
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
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
|
return None
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,21 @@ class ProfileAuthManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
|
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
|
||||||
|
|
||||||
|
# For Local provider, synchronize custom_base_url in router_profiles.yaml
|
||||||
|
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 (
|
from antigravity_provider.router.event_bus import (
|
||||||
EVENT_ACCOUNT_ADDED,
|
EVENT_ACCOUNT_ADDED,
|
||||||
EVENT_ACCOUNT_AUTH_CHANGED,
|
EVENT_ACCOUNT_AUTH_CHANGED,
|
||||||
|
|
@ -417,6 +432,12 @@ class ProfileAuthManager:
|
||||||
if val:
|
if val:
|
||||||
return {"provider": "opencode-go", "profile_id": profile_id, "api_key": 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
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -555,6 +576,55 @@ class ProfileAuthManager:
|
||||||
return True, masked, ["opencode-go-3"]
|
return True, masked, ["opencode-go-3"]
|
||||||
return False, None, []
|
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
|
@classmethod
|
||||||
def get_profile_status(cls, provider: str, profile_id: str) -> Dict[str, Any]:
|
def get_profile_status(cls, provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
"""Check status and metadata for a profile."""
|
"""Check status and metadata for a profile."""
|
||||||
|
|
@ -745,6 +815,31 @@ class ProfileAuthManager:
|
||||||
"error": None,
|
"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 {
|
return {
|
||||||
"authenticated": False,
|
"authenticated": False,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,11 @@ class AccountQuotaService:
|
||||||
|
|
||||||
auth_data = ProfileAuthManager.load_profile_auth(provider, profile_id)
|
auth_data = ProfileAuthManager.load_profile_auth(provider, profile_id)
|
||||||
if not auth_data:
|
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(
|
snap = QuotaSnapshot(
|
||||||
account_id=profile_id,
|
account_id=profile_id,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
|
@ -135,7 +140,9 @@ class AccountQuotaService:
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
try:
|
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)
|
snap = self._collect_antigravity_quota(profile_id, auth_data)
|
||||||
elif provider in ("openai-codex", "codex"):
|
elif provider in ("openai-codex", "codex"):
|
||||||
snap = self._collect_codex_quota(profile_id, auth_data)
|
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")
|
plan_code = auth_data.get("plan_type", "MAX" if "token" in auth_data else "API Key")
|
||||||
elif provider == "grok":
|
elif provider == "grok":
|
||||||
plan_code = auth_data.get("plan_type", "Grok Pro" if "token" in auth_data else "API Key")
|
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")
|
plan = SubscriptionPlan.create(plan_code, source="provider_auth")
|
||||||
|
|
||||||
|
|
@ -795,9 +804,48 @@ class AccountQuotaService:
|
||||||
unavailable_reason="Grok не предоставляет остаток через публичный API",
|
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:
|
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
|
||||||
"""Truthful offline baseline with provider-specific independent limit pools."""
|
"""Truthful offline baseline with provider-specific independent limit pools."""
|
||||||
now = _utc_now()
|
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 = {
|
bucket_specs = {
|
||||||
"antigravity": [
|
"antigravity": [
|
||||||
("antigravity.claude.5h", "Claude 5h", "claude", "5h"),
|
("antigravity.claude.5h", "Claude 5h", "claude", "5h"),
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,23 @@ def get_default_router_config() -> RouterConfig:
|
||||||
preferred_models=["grok-3", "grok-3-mini"],
|
preferred_models=["grok-3", "grok-3-mini"],
|
||||||
max_concurrency=2,
|
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] = {
|
roles: dict[str, RolePolicy] = {
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ class AddAccountWizard(HubModal):
|
||||||
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE),
|
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE),
|
||||||
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK),
|
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK),
|
||||||
("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC),
|
("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)
|
self.provider_var = ctk.StringVar(value=self.selected_provider)
|
||||||
|
|
@ -234,6 +235,8 @@ class AddAccountWizard(HubModal):
|
||||||
self._build_grok_oauth_flow()
|
self._build_grok_oauth_flow()
|
||||||
else:
|
else:
|
||||||
self._build_api_key_flow()
|
self._build_api_key_flow()
|
||||||
|
elif self.selected_provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||||||
|
self._build_local_llm_flow()
|
||||||
else:
|
else:
|
||||||
self._build_api_key_flow()
|
self._build_api_key_flow()
|
||||||
|
|
||||||
|
|
@ -245,6 +248,7 @@ class AddAccountWizard(HubModal):
|
||||||
"opencode-go": "OpenCode Go",
|
"opencode-go": "OpenCode Go",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"grok": "Grok",
|
"grok": "Grok",
|
||||||
|
"local": "Local LLM",
|
||||||
}
|
}
|
||||||
card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED)
|
card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED)
|
||||||
card.pack(fill="x", pady=20)
|
card.pack(fill="x", pady=20)
|
||||||
|
|
@ -1312,6 +1316,187 @@ class AddAccountWizard(HubModal):
|
||||||
if hasattr(self, "key_status_lbl"):
|
if hasattr(self, "key_status_lbl"):
|
||||||
self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}")
|
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
|
# STEP 3: Validation & Identity
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,11 @@ function renderAccountsView() {
|
||||||
'opencode-go': 'OpenCode Go',
|
'opencode-go': 'OpenCode Go',
|
||||||
claude: 'Claude (Anthropic)',
|
claude: 'Claude (Anthropic)',
|
||||||
grok: 'Grok (xAI)',
|
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 || {};
|
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
||||||
|
|
@ -1543,6 +1548,13 @@ function showWizardStep1() {
|
||||||
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (требует браузер или перенос профиля)</div>
|
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (требует браузер или перенос профиля)</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
elements.modalFooter.innerHTML = `
|
elements.modalFooter.innerHTML = `
|
||||||
|
|
@ -1611,6 +1623,20 @@ function showWizardStep2(providerId) {
|
||||||
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Вставьте токен или нажмите Далее...">
|
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Вставьте токен или нажмите Далее...">
|
||||||
</div>
|
</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 {
|
} else {
|
||||||
bodyHtml = `
|
bodyHtml = `
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||||
|
|
@ -1630,10 +1656,22 @@ function showWizardStep2(providerId) {
|
||||||
|
|
||||||
elements.modalFooter.innerHTML = `
|
elements.modalFooter.innerHTML = `
|
||||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
<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) {
|
function showWizardStep3(providerId) {
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div id="modal-feedback-area"></div>
|
<div id="modal-feedback-area"></div>
|
||||||
|
|
@ -1669,10 +1707,18 @@ async function finishAddAccount(providerId) {
|
||||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
|
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await executeAction('add_account', {
|
const payload = {
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
target_role: targetRole,
|
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) {
|
if (res.ok) {
|
||||||
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
|
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class TestA9ConfigMigration(unittest.TestCase):
|
||||||
self.assertIn("claude-orch", migrated_cfg.profiles)
|
self.assertIn("claude-orch", migrated_cfg.profiles)
|
||||||
self.assertIn("claude-worker-1", migrated_cfg.profiles)
|
self.assertIn("claude-worker-1", migrated_cfg.profiles)
|
||||||
self.assertIn("claude-worker-2", 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
|
# 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"]:
|
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
|
# 7. Verify Idempotence: subsequent loads do not create extra backups
|
||||||
backup_count_before = len(backups)
|
backup_count_before = len(backups)
|
||||||
reload_cfg = load_router_config(self.config_path)
|
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_*")))
|
backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*")))
|
||||||
self.assertEqual(backup_count_before, backup_count_after)
|
self.assertEqual(backup_count_before, backup_count_after)
|
||||||
|
|
||||||
|
|
|
||||||
51
tests/test_local_llm_empty_answer.py
Normal file
51
tests/test_local_llm_empty_answer.py
Normal 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)
|
||||||
378
tests/test_local_llm_provider_a25.py
Normal file
378
tests/test_local_llm_provider_a25.py
Normal 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)
|
||||||
|
|
@ -48,9 +48,9 @@ from antigravity_provider.router.cli_commands import (
|
||||||
class TestRouterConfig:
|
class TestRouterConfig:
|
||||||
"""Test configuration schema, profile loading, and role definitions."""
|
"""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()
|
config = get_default_router_config()
|
||||||
assert len(config.profiles) == 22
|
assert len(config.profiles) == 24
|
||||||
# 3 Codex
|
# 3 Codex
|
||||||
assert "codex-orch" in config.profiles
|
assert "codex-orch" in config.profiles
|
||||||
assert "codex-worker-1" in config.profiles
|
assert "codex-worker-1" in config.profiles
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_profile_view_model_mapping():
|
||||||
assert isinstance(p, ProfileViewModel)
|
assert isinstance(p, ProfileViewModel)
|
||||||
assert p.profile_id
|
assert p.profile_id
|
||||||
assert p.display_name
|
assert p.display_name
|
||||||
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok")
|
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local")
|
||||||
assert p.health_state in (
|
assert p.health_state in (
|
||||||
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
|
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
|
||||||
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"
|
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue