Проверка кандидата A28–A31 исполнением. 1. Дублирование ролей. RoleRegistry.migrate_legacy_roles написана верно, но НЕ ВЫЗЫВАЛАСЬ НИОТКУДА — проверено поиском по всему коду. Вместо неё работала «идемпотентная миграция» в router_config, дописывавшая недостающие умолчания и не убиравшая старые роли. На конфигурации владельца интерфейс показывал 19 агентов, причём шесть пар были неотличимы по названию: orchestrator и manager — оба «Менеджер проекта», reviewer и code-reviewer — оба «Ревьюер кода». Разложить аккаунты по такому списку невозможно. Миграция подключена. Внутри неё нашлась вторая ошибка: при обходе одним проходом пустая каноническая роль затирала цепочку, перенесённую из старой. У владельца сработало бы именно так — в manager попал бы codex-orch вместо выставленного им ag-orch-fallback. Старые роли теперь обрабатываются первыми: в них и лежит настроенный порядок аккаунтов. Проверено на живой конфигурации владельца: было 19 ролей, стало 13, все цепочки совпадают с исходными. 2. Сохранённый workflow мигрируется вместе с ролями. Идентификаторы агентов повторяют идентификаторы ролей, и без переименования рёбра ссылались бы на исчезнувших агентов — «Ребро ссылается на отсутствующего агента». 3. Поиск локальных серверов моделей. Раньше адрес вводился руками. Теперь опрашиваются известные порты на петле — Ollama 11434, LM Studio 1234, llama.cpp 8080-8082, vLLM 8000, Jan, GPT4All, Text Generation WebUI, — параллельно, девять портов за 1.5 с. Наружу идёт только то, что ответило; список моделей берётся у сервера. Порт, занятый чужим сервисом, показывается с причиной, закрытые не показываются вовсе. Действие discover_local_models. Роль в фикстурах test_workflow_service_a30 переименована в test-developer: «developer» — псевдоним канонической developer-1, и тест проверял бы работу псевдонимов вместо механики workflow. 475 passed, 2 skipped; ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
5.9 KiB
Python
124 lines
5.9 KiB
Python
"""Поиск локальных серверов моделей, уже запущенных на машине.
|
||
|
||
Раньше адрес локального сервера вводился руками: владелец должен был помнить,
|
||
на каком порту у него llama.cpp, Ollama или LM Studio. Здесь опрашиваются
|
||
известные порты на петле, и наружу отдаётся только то, что **ответило**.
|
||
|
||
Правило честности действует и тут: ни одного порта, который не ответил, в
|
||
результат не попадает, и ни одна модель не выдумывается — список берётся у
|
||
самого сервера.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import concurrent.futures
|
||
import json
|
||
import logging
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
logger = logging.getLogger("hermes.router.local_discovery")
|
||
|
||
# Порты, на которых эти серверы работают из коробки. Список — умолчание, а не
|
||
# исчерпывающая истина: владелец всегда может ввести адрес сам.
|
||
WELL_KNOWN_ENDPOINTS: List[Dict[str, Any]] = [
|
||
{"name": "Ollama", "port": 11434, "base_path": "/v1"},
|
||
{"name": "LM Studio", "port": 1234, "base_path": "/v1"},
|
||
{"name": "llama.cpp", "port": 8080, "base_path": "/v1"},
|
||
{"name": "llama.cpp", "port": 8081, "base_path": "/v1"},
|
||
{"name": "llama.cpp", "port": 8082, "base_path": "/v1"},
|
||
{"name": "vLLM", "port": 8000, "base_path": "/v1"},
|
||
{"name": "Jan", "port": 1337, "base_path": "/v1"},
|
||
{"name": "GPT4All", "port": 4891, "base_path": "/v1"},
|
||
{"name": "Text Generation WebUI", "port": 5000, "base_path": "/v1"},
|
||
]
|
||
|
||
DEFAULT_HOST = "127.0.0.1"
|
||
PROBE_TIMEOUT_SEC = 1.5
|
||
|
||
|
||
@dataclass
|
||
class LocalServer:
|
||
"""Обнаруженный локальный сервер моделей."""
|
||
|
||
name: str
|
||
base_url: str
|
||
models: List[str] = field(default_factory=list)
|
||
error: Optional[str] = None
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"name": self.name,
|
||
"base_url": self.base_url,
|
||
"models": list(self.models),
|
||
"model_count": len(self.models),
|
||
"error": self.error,
|
||
}
|
||
|
||
|
||
def _fetch_models(base_url: str, timeout: float) -> List[str]:
|
||
"""Спросить у сервера список моделей по OpenAI-совместимому /models."""
|
||
url = base_url.rstrip("/") + "/models"
|
||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
if resp.status != 200:
|
||
raise ValueError(f"HTTP {resp.status}")
|
||
payload = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||
|
||
models: List[str] = []
|
||
for item in payload.get("data") or []:
|
||
if isinstance(item, dict) and item.get("id"):
|
||
models.append(str(item["id"]))
|
||
elif isinstance(item, str):
|
||
models.append(item)
|
||
return models
|
||
|
||
|
||
def probe_endpoint(host: str, port: int, base_path: str, name: str,
|
||
timeout: float = PROBE_TIMEOUT_SEC) -> Optional[LocalServer]:
|
||
"""Опросить один адрес. Не ответил — вернуть None, а не пустую заглушку."""
|
||
base_url = f"http://{host}:{port}{base_path}"
|
||
try:
|
||
models = _fetch_models(base_url, timeout)
|
||
except (urllib.error.URLError, OSError, TimeoutError):
|
||
# Порт закрыт или сервер не отвечает — это не находка и не ошибка.
|
||
return None
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
# Порт занят чем-то, что говорит не на том языке. Показываем как
|
||
# найденный, но с причиной: иначе владелец будет гадать, почему
|
||
# заведомо работающий сервер не виден.
|
||
logger.debug("Порт %s занят не тем сервисом: %s", port, exc)
|
||
return LocalServer(name=f"{name} (порт {port})", base_url=base_url,
|
||
error="Сервер на этом порту ответил не по OpenAI-совместимому протоколу")
|
||
return LocalServer(name=f"{name} (порт {port})", base_url=base_url, models=models)
|
||
|
||
|
||
def discover_local_servers(host: str = DEFAULT_HOST,
|
||
endpoints: Optional[List[Dict[str, Any]]] = None,
|
||
timeout: float = PROBE_TIMEOUT_SEC) -> List[LocalServer]:
|
||
"""Опросить известные порты параллельно и вернуть только ответившие.
|
||
|
||
Опрос идёт параллельно: последовательно девять закрытых портов дали бы
|
||
заметную паузу в интерфейсе.
|
||
"""
|
||
targets = endpoints if endpoints is not None else WELL_KNOWN_ENDPOINTS
|
||
found: List[LocalServer] = []
|
||
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(targets) or 1)) as pool:
|
||
futures = {
|
||
pool.submit(probe_endpoint, host, t["port"], t.get("base_path", "/v1"), t["name"], timeout): t
|
||
for t in targets
|
||
}
|
||
for fut in concurrent.futures.as_completed(futures):
|
||
try:
|
||
result = fut.result()
|
||
except Exception as exc: # опрос не должен ронять вызывающего
|
||
logger.warning("Ошибка опроса локального сервера: %s", exc)
|
||
continue
|
||
if result is not None:
|
||
found.append(result)
|
||
|
||
found.sort(key=lambda s: (s.error is not None, -len(s.models), s.base_url))
|
||
return found
|