hermes-hub/scripts/setup_local_models.py
Hermes Team ab2ee12db4 feat(local): скрипт прописывания локальных моделей и назначения ролей
У владельца на сервере два llama.cpp: Qwen3.8-27B на 8081 под тяжёлую
разработку и Qwen3-4B на 8082 под служебные роли — cost-controller,
dependency-agent, tech-writer, tester и суммаризацию.

Скрипт прописывает оба профиля и добавляет их в цепочки соответствующих
ролей. Дополняет конфигурацию, не переписывает: чужие профили и порядок
аккаунтов сохраняются, повторный запуск ничего не меняет.

Идентификаторы моделей не выдумываются — спрашиваются у самих серверов через
/v1/models. Сервер не ответил — профиль не трогается, причина названа.
Проверено: при отсутствии серверов скрипт отказывается и объясняет.

max_concurrency выставляется в 1: у обоих серверов --parallel 1, и больше
единицы означает очередь и лавину таймаутов.

Локальный профиль ставится ПОСЛЕДНИМ в цепочке: модель бесплатна и не
исчерпывается, поэтому она хороший последний рубеж, когда платные квоты
кончились. Существующий порядок при этом не переставляется.

Есть обратное соответствие для сборок, где тринадцати ролей ещё нет:
developer-1 -> coder-primary, tech-writer и tester -> fast, а служебные роли
пропускаются с явным сообщением, потому что аналога им там нет. Проверено на
обоих наборах ролей.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:32:01 +07:00

186 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Прописать локальные модели в Hermes Hub и назначить им роли.
Запускать на той машине, где работает хаб:
python3 scripts/setup_local_models.py # показать, что будет сделано
python3 scripts/setup_local_models.py --yes # применить
Скрипт ДОПОЛНЯЕТ конфигурацию, а не переписывает: чужие профили, роли и
цепочки сохраняются. Запись атомарная, повторный запуск ничего не ломает.
Идентификаторы моделей не выдумываются — они спрашиваются у самих серверов
через /v1/models. Сервер не ответил — профиль не трогаем и говорим об этом.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
# Замысел владельца: тяжёлая 27B на разработку, лёгкая 4B на служебные роли.
LOCAL_PLAN = [
{
"profile_id": "local-1",
"base_url": "http://127.0.0.1:8081/v1",
"title": "Qwen3.8-27B (тяжёлый локальный кодер)",
"roles": ["developer-1"],
},
{
"profile_id": "local-2",
"base_url": "http://127.0.0.1:8082/v1",
"title": "Qwen3-4B (служебная модель)",
"roles": ["cost-controller", "dependency-agent", "tech-writer", "tester"],
},
]
def ask_models(base_url: str, timeout: float = 6.0):
"""Спросить у сервера список моделей. Ничего не выдумывать."""
url = base_url.rstrip("/") + "/models"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8", "replace"))
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as exc:
return None, f"{type(exc).__name__}: {exc}"
ids = [m["id"] for m in payload.get("data", []) if isinstance(m, dict) and m.get("id")]
return ids, None
# Соответствие для СТАРЫХ сборок, где тринадцати ролей ещё нет. Служебные
# роли там отсутствуют как понятие, поэтому 4B назначается на быстрые роли —
# это ближайшее по смыслу, а не подмена.
LEGACY_FALLBACK = {
"developer-1": "coder-primary",
"developer-2": "coder-secondary",
"code-reviewer": "reviewer",
"researcher": "research",
"tester": "fast",
"tech-writer": "fast",
"cost-controller": None,
"dependency-agent": None,
}
def resolve_role(name: str, known_roles) -> str | None:
"""Привести роль к имени, которое понимает установленная сборка."""
try:
from antigravity_provider.router.role_registry import RoleRegistry
canon = RoleRegistry.resolve_canonical_role(name)
except Exception:
canon = name
if canon in known_roles:
return canon
# Сборка старая: пробуем ближайшую по смыслу роль из шести.
legacy = LEGACY_FALLBACK.get(canon, canon)
if legacy and legacy in known_roles:
return legacy
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--yes", action="store_true", help="применить изменения")
args = parser.parse_args()
from antigravity_provider.paths import get_router_profiles_path
from antigravity_provider.router.router_config import load_router_config, save_router_config
target = get_router_profiles_path()
print(f"Файл конфигурации : {target}")
print(f" существует : {'да' if target.is_file() else 'нет, будет создан'}")
if os.environ.get("HERMES_HOME"):
print(f" HERMES_HOME : {os.environ['HERMES_HOME']}")
print()
config = load_router_config()
known_roles = set(config.roles)
print(f"Ролей в конфигурации: {len(known_roles)}")
print()
planned = []
for item in LOCAL_PLAN:
pid = item["profile_id"]
models, err = ask_models(item["base_url"])
if err:
print(f" {pid}: сервер {item['base_url']} не ответил — {err}")
print(" профиль не трогаю; запустите сервер и повторите")
continue
roles_ok, roles_missing = [], []
for r in item["roles"]:
resolved = resolve_role(r, known_roles)
if resolved and resolved not in roles_ok:
roles_ok.append(resolved)
elif not resolved:
roles_missing.append(r)
print(f" {pid}: {item['title']}")
print(f" адрес : {item['base_url']}")
print(f" моделей : {len(models)} -> {', '.join(m.split('/')[-1] for m in models)}")
print(f" роли : {', '.join(roles_ok) if roles_ok else '(нет подходящих)'}")
if roles_missing:
print(f" пропуск : {', '.join(roles_missing)} — таких ролей в этой сборке нет")
planned.append({**item, "models": models, "roles_ok": roles_ok})
if not planned:
print("\nНечего применять.")
return 1
if not args.yes:
print("\nПоказан предпросмотр. Чтобы применить, повторите с --yes")
return 0
from antigravity_provider.router.router_config import RouterProfileConfig
for item in planned:
pid = item["profile_id"]
existing = config.get_profile(pid)
if existing is None:
config.profiles[pid] = RouterProfileConfig(
profile_id=pid,
provider="local",
account_id=pid,
capabilities=["coding", "reasoning", "fast", "research"],
preferred_models=list(item["models"]),
custom_base_url=item["base_url"],
# У обоих серверов --parallel 1: один запрос за раз.
# Больше единицы означает очередь и лавину таймаутов.
max_concurrency=1,
)
else:
existing.custom_base_url = item["base_url"]
existing.preferred_models = list(item["models"])
existing.max_concurrency = 1
existing.enabled = True
# Профиль ставится ПОСЛЕДНИМ в цепочке роли: локальная модель бесплатна
# и не исчерпывается, поэтому она хороший последний рубеж, когда платные
# квоты кончились. Существующий порядок при этом не переставляется.
for role_id in item["roles_ok"]:
chain = list(config.roles[role_id].preferred_chain)
if pid not in chain:
chain.append(pid)
config.roles[role_id].preferred_chain = chain
save_router_config(config)
print(f"\nГотово: {target}")
print("\nИтоговые цепочки затронутых ролей:")
fresh = load_router_config()
for item in planned:
for role_id in item["roles_ok"]:
print(f" {role_id:20} {list(fresh.roles[role_id].preferred_chain)}")
print("\nПерезапустите хаб, чтобы он перечитал конфигурацию.")
return 0
if __name__ == "__main__":
raise SystemExit(main())