Files
gpu-rent/src/gpu_rent/llm_runtime.py
T
Leonid PershinandCursor 4081890b4c Support Ollama use: chat|memory and parallel embed beside VL.
Pull nomic-embed-text for Assistent memory, write ollama-roles.json, CPU Modelfile, and raise MAX_LOADED_MODELS/NUM_PARALLEL to 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 22:47:03 +03:00

321 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Optional LLM runtime (Ollama) beside SwarmUI."""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import yaml
from gpu_rent.paths import (
ollama_models_example_path,
ollama_models_manifest_path,
)
VALID_RUNTIMES = frozenset({"none", "ollama"})
MEMORY_EMBED_MODEL = "nomic-embed-text"
OLLAMA_PRESETS: dict[str, list[str]] = {
# Requirement: uncensored (abliterated) + solid Russian. Qwen2.5 family.
# Vision tags preferred for SwarmUI prompt help with images.
# Memory embed (nomic) is appended separately with use: memory.
"recommended": ["huihui_ai/qwen2.5-vl-abliterated:7b"], # ~6GB
"light": ["huihui_ai/qwen2.5-vl-abliterated:3b"], # ~3GB
"text": ["huihui_ai/qwen2.5-abliterate:7b"], # ~5GB, no vision
"big": ["huihui_ai/qwen2.5-vl-abliterated:32b"], # ~21GB
"empty": [],
}
OLLAMA_PRESET_LABELS: dict[str, str] = {
"recommended": "VL 7B abliterate — RU + картинки (~6GB)",
"light": "VL 3B abliterate — RU + картинки, мало VRAM (~3GB)",
"text": "7B abliterate text — RU, без vision (~5GB)",
"big": "VL 32B abliterate — RU + картинки (~21GB)",
"empty": "только runtime, без pull",
"keep": "не менять ollama-models.yaml",
}
# Deprecated text blob — prefer menu helpers below.
PRESET_HELP = "\n".join(
f"{k}{v}" for k, v in OLLAMA_PRESET_LABELS.items() if k != "keep"
)
LLM_RUNTIME_LABELS: dict[str, str] = {
"none": "только SwarmUI",
"ollama": "Ollama (+ pull моделей)",
}
WORKLOAD_LABELS: dict[str, str] = {
"swarm": "только SwarmUI",
"both": "SwarmUI + LLM",
"llm": "только LLM (без SwarmUI)",
}
def llm_runtime_menu() -> list:
from gpu_rent.prompts import MenuItem
return [MenuItem(k, f"{k}{LLM_RUNTIME_LABELS[k]}") for k in ("none", "ollama")]
def workload_menu() -> list:
from gpu_rent.prompts import MenuItem
return [MenuItem(k, WORKLOAD_LABELS[k]) for k in ("swarm", "both", "llm")]
def ollama_preset_menu(*, include_keep: bool = False) -> list:
from gpu_rent.prompts import MenuItem
keys = list(OLLAMA_PRESETS.keys())
if include_keep:
keys.append("keep")
return [MenuItem(k, OLLAMA_PRESET_LABELS.get(k, k)) for k in keys]
@dataclass(frozen=True)
class OllamaModelEntry:
name: str
default: bool = False
use: str = "chat" # chat | memory
def _normalize_use(raw: object) -> str:
s = str(raw or "chat").strip().lower()
if s in {"memory", "embed", "embedding"}:
return "memory"
return "chat"
def normalize_runtime(value: str | None) -> str:
raw = (value or "none").strip().lower().replace("-", "").replace("_", "")
if raw in {"", "none", "off", "no", "0"}:
return "none"
if raw in {"ollama"}:
return "ollama"
raise ValueError(f"неизвестный LLM_RUNTIME={value!r}; жду none|ollama")
def decide_runtime(
*,
flag: str | None,
ollama_flag: bool,
from_config: str,
) -> str:
if ollama_flag:
return "ollama"
if flag is not None and str(flag).strip() != "":
return normalize_runtime(flag)
return normalize_runtime(from_config)
def parse_ollama_models(path: Path) -> list[OllamaModelEntry]:
if not path.is_file():
return []
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return []
items = raw.get("models")
if items is None:
return []
if not isinstance(items, list):
raise ValueError(f"{path}: models должен быть списком")
out: list[OllamaModelEntry] = []
for item in items:
if isinstance(item, str):
name = item.strip()
if name:
out.append(OllamaModelEntry(name=name, use="chat"))
continue
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
use = _normalize_use(item.get("use") or item.get("role"))
out.append(
OllamaModelEntry(
name=name,
default=bool(item.get("default")) and use == "chat",
use=use,
)
)
return out
def ensure_memory_model_entries(entries: list[OllamaModelEntry]) -> list[OllamaModelEntry]:
"""Append default memory embed if the manifest has chat models but no memory."""
if not entries:
return entries
if any(e.use == "memory" for e in entries):
return entries
if all(e.use == "memory" for e in entries):
return entries
return list(entries) + [
OllamaModelEntry(name=MEMORY_EMBED_MODEL, default=False, use="memory")
]
def already_have_ollama_tag(have: set[str], wanted: str) -> bool:
"""Exact tag match only — qwen2.5:3b must not satisfy qwen2.5:7b."""
if wanted in have:
return True
if ":" not in wanted and f"{wanted}:latest" in have:
return True
if wanted.endswith(":latest") and wanted.rsplit(":", 1)[0] in have:
return True
return False
def preferred_ollama_model(path: Path) -> str | None:
"""Manifest default chat model, else first chat tag (never memory/embed)."""
entries = [e for e in parse_ollama_models(path) if e.use == "chat"]
for entry in entries:
if entry.default:
return entry.name
return entries[0].name if entries else None
def ollama_roles_payload(entries: list[OllamaModelEntry]) -> dict[str, list[str]]:
chat = [e.name for e in entries if e.use == "chat"]
memory = [e.name for e in entries if e.use == "memory"]
return {"chat": chat, "memory": memory}
def _ollama_ps_names(payload: object) -> set[str]:
names: set[str] = set()
if not isinstance(payload, dict):
return names
for item in payload.get("models") or []:
if isinstance(item, dict):
for key in ("name", "model"):
val = item.get(key)
if val:
names.add(str(val))
elif isinstance(item, str) and item.strip():
names.add(item.strip())
return names
def warmup_ollama_http(
base_url: str,
model: str,
*,
keep_alive: str = "15m",
num_ctx: int = 16384,
timeout: float = 180.0,
) -> str:
"""Load ``model`` into VRAM via a 1-token /api/chat. Skip if /api/ps already has it."""
base = str(base_url).rstrip("/")
t0 = time.monotonic()
timeout_cfg = httpx.Timeout(timeout, connect=8.0)
with httpx.Client(timeout=timeout_cfg) as client:
try:
ps = client.get(f"{base}/api/ps", timeout=8.0)
ps.raise_for_status()
if already_have_ollama_tag(_ollama_ps_names(ps.json()), model):
return f"Ollama warmup skip — {model} уже в VRAM"
except (httpx.HTTPError, ValueError, TypeError):
pass
body = {
"model": model,
"stream": False,
"keep_alive": keep_alive,
"options": {
"num_predict": 1,
"num_ctx": int(num_ctx),
"temperature": 0,
},
"messages": [{"role": "user", "content": "ok"}],
}
resp = client.post(f"{base}/api/chat", json=body)
resp.raise_for_status()
elapsed = time.monotonic() - t0
return f"Ollama warmup {model} ok ({elapsed:.1f}s)"
def maybe_warmup_ollama_local(cfg: Any, log: Callable[[str], None]) -> None:
"""After the laptop tunnel is up: load preferred tag if VRAM is cold."""
try:
if normalize_runtime(getattr(cfg, "llm_runtime", "none")) != "ollama":
return
except ValueError:
return
manifest = Path(getattr(cfg, "ollama_models_manifest", "") or "")
model = preferred_ollama_model(manifest)
if not model:
return
port = int(getattr(cfg, "ollama_local_port", 17811) or 17811)
log(f"Ollama warmup {model} (VRAM)")
try:
log(
warmup_ollama_http(
f"http://127.0.0.1:{port}",
model,
)
)
except Exception as exc:
log(f"⚠ Ollama warmup: {exc}")
def write_ollama_models_preset(path: Path, preset: str) -> None:
key = (preset or "recommended").strip().lower()
if key not in OLLAMA_PRESETS:
raise ValueError(f"пресет {preset!r}; варианты: {', '.join(OLLAMA_PRESETS)}")
names = OLLAMA_PRESETS[key]
lines = [
"# Локальный манифест Ollama (не коммить). Пример: ollama-models.example.yaml",
"# name = точный тег для `ollama pull`.",
"# use: chat — селект Assistent; use: memory — модель памяти (⚙).",
"# Пустой models: [] — без pull.",
"models:",
]
if not names:
lines.append(" []")
else:
for i, name in enumerate(names):
lines.append(f" - name: {name}")
lines.append(" use: chat")
if i == 0:
lines.append(" default: true")
lines.append(f" - name: {MEMORY_EMBED_MODEL}")
lines.append(" use: memory")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def ensure_ollama_manifest_from_example() -> Path:
dest = ollama_models_manifest_path()
if dest.is_file():
return dest
example = ollama_models_example_path()
if example.is_file():
dest.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
else:
write_ollama_models_preset(dest, "recommended")
return dest
def llm_local_port(cfg: Any) -> int | None:
runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none"))
if runtime == "ollama":
return int(getattr(cfg, "ollama_local_port", 17811))
return None
def llm_remote_port(runtime: str) -> int | None:
runtime = normalize_runtime(runtime)
if runtime == "ollama":
return 11434
return None
def append_vars_llm_runtime(vars_file: Path, runtime: str) -> None:
from gpu_rent.varsfile import upsert_vars
upsert_vars(vars_file, {"LLM_RUNTIME": normalize_runtime(runtime)})