Stop stacking Ollama -cpu embed tags and verify Assistent Sqlite after seed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 06:45:04 +03:00
co-authored by Cursor
parent 2d404436bd
commit ca11467a0d
6 changed files with 367 additions and 43 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ comfy:
| `ollama` | только при `LLM_RUNTIME=ollama` |
| `any-llm` | при `LLM_RUNTIME=ollama` |
Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). После `seed-extensions` + рестарта SwarmUI подтягивается текущий `main` (с **0.10.11**: default chat = `default_chat` / старший тег, warm той же моделью). На `up``gpu-rent seed-personas`) сидится папка `assistent-personas/` → overlay `/mnt/swarm_data/Assistent/personas/<id>/*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся.
Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). После `seed-extensions` + рестарта SwarmUI подтягивается текущий `main` (с **0.13.1+**: `CopyLocalLockFileAssemblies` копирует `Microsoft.Data.Sqlite` + `SQLitePCLRaw*` рядом с extension DLL — иначе ListMemory/ListChats/SaveChat падают; с **0.10.11**: default chat = `default_chat` / старший тег, warm той же моделью). На `up``gpu-rent seed-personas`) сидится папка `assistent-personas/` → overlay `/mnt/swarm_data/Assistent/personas/<id>/*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся.
В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера.
+4
View File
@@ -971,10 +971,14 @@ def pull_output_cmd() -> None:
def seed_extensions_cmd() -> None:
"""Clone/fetch extensions.yaml, затем restart swarmui."""
try:
from gpu_rent.provision import verify_assistent_sqlite_bins
cfg, host = _live()
seed_extensions(cfg, host, log)
ensure_swarmui_running(cfg, host, log, restart=True)
# Build lands Sqlite private deps beside the extension DLL (≥0.13.1).
verify_assistent_sqlite_bins(cfg, host, log)
except GpuRentError as exc:
_die(exc)
+25 -7
View File
@@ -30,9 +30,13 @@ for root in roots:
for p in sorted(root.iterdir()):
if "assistent" not in p.name.lower():
continue
dll_dir = p / "bin" / "Debug" / "net8.0"
dll = dll_dir / "SwarmAssistentExtension.dll"
sqlite_dll = dll_dir / "Microsoft.Data.Sqlite.dll"
dlls = sorted(p.glob("bin/**/SwarmAssistentExtension.dll"))
if not dlls:
# Older layout / alternate assembly name
dlls = sorted(p.glob("bin/**/*Assistent*.dll"))
dll = dlls[0] if dlls else None
dll_dir = dll.parent if dll else None
sqlite_dll = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None
csproj = next(p.glob("*.csproj"), None)
tab = p / "Tabs" / "Text2Image" / "Assistent.html"
bundle = p / "Assets" / "assistent.bundle.js"
@@ -49,10 +53,11 @@ for root in roots:
"path": str(p),
"name": p.name,
"csproj": str(csproj) if csproj else None,
"dll": str(dll) if dll.is_file() else None,
"dll_mtime": int(dll.stat().st_mtime) if dll.is_file() else None,
"sqlite_dll": sqlite_dll.is_file(),
"sqlitepcl": any(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir.is_dir() else False,
"dll": str(dll) if dll else None,
"dll_mtime": int(dll.stat().st_mtime) if dll else None,
"dll_dir": str(dll_dir) if dll_dir else None,
"sqlite_dll": bool(sqlite_dll and sqlite_dll.is_file()),
"sqlitepcl": any(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir else False,
"tab_html": tab.is_file(),
"bundle_js": bundle.is_file(),
"git_head": head or None,
@@ -412,6 +417,19 @@ def collect_assistent_roles(cfg: Config, *, fs: dict[str, Any] | None = None) ->
missing_mem = [m for m in memory if models and m not in models]
if missing_chat:
ok = False
from gpu_rent.llm_runtime import is_stacked_cpu_ollama_tag
stacked = [
m
for m in list(memory) + [x for x in models if isinstance(x, str)]
if isinstance(m, str) and is_stacked_cpu_ollama_tag(m)
]
if stacked:
hints.append(
"recursive *-cpu Ollama tags — roles.memory должен быть nomic-embed-text-cpu; "
"ollama rm … затем re-up / provision_llm"
)
ok = False
return {
"ok": ok,
"roles": {
+94 -1
View File
@@ -173,6 +173,96 @@ def already_have_ollama_tag(have: set[str], wanted: str) -> bool:
return False
def ollama_model_root(name: str) -> str:
"""Strip ``:tag`` and any trailing ``-cpu`` suffixes.
``nomic-embed-text-cpu-cpu:latest`` → ``nomic-embed-text``.
"""
base = str(name or "").split(":", 1)[0].strip()
while base.endswith("-cpu"):
base = base[: -len("-cpu")]
return base
def is_cpu_ollama_tag(name: str) -> bool:
"""True if the model name (sans ``:tag``) ends with ``-cpu``."""
return str(name or "").split(":", 1)[0].endswith("-cpu")
def is_stacked_cpu_ollama_tag(name: str) -> bool:
"""True for recursive junk like ``nomic-embed-text-cpu-cpu``."""
base = str(name or "").split(":", 1)[0]
if not base.endswith("-cpu"):
return False
return base[: -len("-cpu")].endswith("-cpu")
def cpu_ollama_tag(name: str) -> str:
"""Single CPU variant tag: ``nomic-embed-text`` / ``…-cpu-cpu`` → ``nomic-embed-text-cpu``."""
root = ollama_model_root(name)
return f"{root}-cpu" if root else ""
def pick_ollama_from_tag(have: set[str], wanted: str) -> str | None:
"""Pick a FROM tag for a CPU Modelfile — prefer the non-cpu library pull."""
root = ollama_model_root(wanted)
if not root:
return None
for candidate in (root, f"{root}:latest"):
if already_have_ollama_tag(have, candidate):
if candidate in have:
return candidate
if f"{root}:latest" in have:
return f"{root}:latest"
if root in have:
return root
non_cpu: list[str] = []
single_cpu: list[str] = []
for tag in sorted(have):
if ollama_model_root(tag) != root:
continue
if is_stacked_cpu_ollama_tag(tag):
continue
if is_cpu_ollama_tag(tag):
single_cpu.append(tag)
else:
non_cpu.append(tag)
if non_cpu:
return non_cpu[0]
if single_cpu:
return single_cpu[0]
return None
def finalize_memory_role_tags(
memory: list[str],
*,
prefer_cpu: bool = True,
) -> list[str]:
"""One tag per embed root; collapse stacked ``-cpu`` names; optionally prefer ``*-cpu``."""
by_root: dict[str, str] = {}
for raw in memory:
name = str(raw or "").strip()
if not name:
continue
root = ollama_model_root(name)
if not root:
continue
curated = cpu_ollama_tag(name) if is_cpu_ollama_tag(name) else name
# Always normalize stacked cpu → single ``*-cpu``
if is_stacked_cpu_ollama_tag(name):
curated = cpu_ollama_tag(name)
cur = by_root.get(root)
if cur is None:
by_root[root] = curated
continue
if prefer_cpu and is_cpu_ollama_tag(curated) and not is_cpu_ollama_tag(cur):
by_root[root] = curated
elif is_stacked_cpu_ollama_tag(cur) and not is_stacked_cpu_ollama_tag(curated):
by_root[root] = curated
return list(by_root.values())
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"]
@@ -185,7 +275,10 @@ def preferred_ollama_model(path: Path) -> str | None:
def ollama_roles_payload(entries: list[OllamaModelEntry]) -> dict[str, list[str] | str | None]:
"""Sidecar for Assistent: chat/memory lists + preferred default_chat (manifest default: true)."""
chat = [e.name for e in entries if e.use == "chat"]
memory = [e.name for e in entries if e.use == "memory"]
memory = finalize_memory_role_tags(
[e.name for e in entries if e.use == "memory"],
prefer_cpu=False,
)
preferred = next((e.name for e in entries if e.use == "chat" and e.default), None)
if preferred is None and chat:
preferred = chat[0]
+190 -34
View File
@@ -197,7 +197,144 @@ def seed_extensions(cfg: Config, host: str, log: Log, *, update: bool = True) ->
timeout=1800,
log=log,
)
return "cloned " in out or "updated " in out
changed = "cloned " in out or "updated " in out
_log_assistent_source_sqlite_hint(cfg, host, log)
return changed
_ASSISTENT_SQLITE_SRC_PY = r"""
from pathlib import Path
roots = [Path("/mnt/swarm_data/Extensions"), Path("/opt/swarmui/src/Extensions")]
found = []
for root in roots:
if not root.is_dir():
continue
for p in sorted(root.iterdir()):
if "assistent" not in p.name.lower():
continue
csproj = next(p.glob("*.csproj"), None)
text = csproj.read_text(encoding="utf-8", errors="replace") if csproj else ""
found.append({
"path": str(p),
"name": p.name,
"has_csproj": bool(csproj),
"has_sqlite_pkg": "Microsoft.Data.Sqlite" in text,
"has_copy_local": "CopyLocalLockFileAssemblies" in text,
"has_ship_target": "ShipOnlySqlitePrivateDeps" in text,
})
print(__import__("json").dumps(found))
"""
_ASSISTENT_SQLITE_BIN_PY = r"""
from pathlib import Path
import json
roots = [Path("/mnt/swarm_data/Extensions"), Path("/opt/swarmui/src/Extensions")]
found = []
for root in roots:
if not root.is_dir():
continue
for p in sorted(root.iterdir()):
if "assistent" not in p.name.lower():
continue
dlls = sorted(p.glob("bin/**/SwarmAssistentExtension.dll"))
dll = dlls[0] if dlls else None
dll_dir = dll.parent if dll else None
sqlite = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None
pcl = list(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir else []
found.append({
"path": str(p),
"name": p.name,
"dll": str(dll) if dll else None,
"sqlite_dll": bool(sqlite and sqlite.is_file()),
"sqlitepcl": bool(pcl),
})
print(json.dumps(found))
"""
def _log_assistent_source_sqlite_hint(cfg: Config, host: str, log: Log) -> None:
"""After clone: warn if Assistent csproj lacks Sqlite private-dep wiring (≥0.13.1)."""
try:
raw = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + _ASSISTENT_SQLITE_SRC_PY + "\nPY",
check=False,
timeout=20,
).strip()
if not raw:
return
rows = json.loads(raw.splitlines()[-1])
except Exception:
return
if not isinstance(rows, list) or not rows:
return
for row in rows:
if not isinstance(row, dict):
continue
name = row.get("name") or "assistent"
if not row.get("has_csproj"):
continue
ok = (
row.get("has_sqlite_pkg")
and row.get("has_copy_local")
and row.get("has_ship_target")
)
if ok:
log(
f"Assistent {name}: csproj Sqlite private deps OK "
"(нужен restart SwarmUI, чтобы dll+Sqlite оказались в bin/)"
)
else:
log(
f"⚠ Assistent {name}: csproj без Sqlite private deps "
"(нужен swarm-assistent ≥0.13.1 на main — seed-extensions с update)"
)
def verify_assistent_sqlite_bins(cfg: Config, host: str, log: Log) -> bool:
"""After SwarmUI build/restart: Microsoft.Data.Sqlite (+ SQLitePCLRaw) beside extension DLL."""
try:
raw = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + _ASSISTENT_SQLITE_BIN_PY + "\nPY",
check=False,
timeout=25,
).strip()
if not raw:
return False
rows = json.loads(raw.splitlines()[-1])
except Exception as exc:
log(f"⚠ Assistent Sqlite bin check: {exc}")
return False
if not isinstance(rows, list) or not rows:
log("⚠ Assistent: extension dir не найден — seed-extensions?")
return False
ok_any = False
for row in rows:
if not isinstance(row, dict):
continue
name = row.get("name") or "assistent"
if not row.get("dll"):
log(
f"⚠ Assistent {name}: DLL ещё нет в bin/ — подожди compile после restart "
"или смотри journalctl -u swarmui"
)
continue
if row.get("sqlite_dll") and row.get("sqlitepcl"):
log(f"Assistent {name}: Microsoft.Data.Sqlite + SQLitePCLRaw рядом с DLL")
ok_any = True
elif row.get("sqlite_dll"):
log(f"Assistent {name}: Microsoft.Data.Sqlite есть (SQLitePCLRaw не найден)")
ok_any = True
else:
log(
f"⚠ Assistent {name}: DLL есть, но нет Microsoft.Data.Sqlite.dll — "
"чат/memory API упадут. Нужен ≥0.13.1 + seed-extensions + restart"
)
return ok_any
def _github_blob(cfg: Config) -> dict | None:
@@ -1193,10 +1330,16 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.llm_runtime import (
MEMORY_EMBED_MODEL,
already_have_ollama_tag,
cpu_ollama_tag,
ensure_memory_model_entries,
finalize_memory_role_tags,
is_cpu_ollama_tag,
is_stacked_cpu_ollama_tag,
normalize_runtime,
ollama_model_root,
ollama_roles_payload,
parse_ollama_models,
pick_ollama_from_tag,
preferred_ollama_model,
)
from gpu_rent.ssh_ops import run_script_sudo
@@ -1283,31 +1426,30 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
# Sidecar roles for Assistent (chat vs memory selects)
roles = ollama_roles_payload(entries)
run_ssh(cfg, host, f"mkdir -p {DATA}/Assistent", check=False)
put_text(
cfg,
host,
f"{DATA}/Assistent/ollama-roles.json",
json.dumps(roles, indent=2, ensure_ascii=False) + "\n",
)
log(f"Assistent ollama-roles: chat={len(roles['chat'])} memory={len(roles['memory'])}")
# Pin memory models to CPU (num_gpu 0) so they run beside chat VL
# Pin memory models to CPU (num_gpu 0) so they run beside chat VL.
# Never stack ``-cpu`` onto an already-cpu tag (re-provision used to
# create nomic-embed-text-cpu-cpu-… and point roles.memory at junk).
mem_roots: list[str] = []
for mem in roles["memory"] or [MEMORY_EMBED_MODEL]:
if not already_have_ollama_tag(have, mem) and not already_have_ollama_tag(
have, mem.split(":")[0]
):
root = ollama_model_root(str(mem))
if root and root not in mem_roots:
mem_roots.append(root)
memory_out: list[str] = []
for root in mem_roots:
cpu_tag = cpu_ollama_tag(root)
from_tag = pick_ollama_from_tag(have, root)
if from_tag is None:
log(f"⚠ memory model {root} нет в /api/tags — skip CPU pin")
memory_out.append(root)
continue
base = mem
# Prefer exact tag present in /api/tags
for tag in sorted(have):
if tag == mem or tag.startswith(mem.split(":")[0]):
base = tag
break
cpu_tag = f"{base.split(':')[0]}-cpu"
if already_have_ollama_tag(have, cpu_tag):
if cpu_tag not in roles["memory"]:
roles["memory"].append(cpu_tag)
memory_out.append(cpu_tag)
continue
modelfile = f"FROM {base}\nPARAMETER num_gpu 0\n"
if is_cpu_ollama_tag(from_tag):
# Only a (single) cpu variant exists — do not create *-cpu-cpu
memory_out.append(cpu_ollama_tag(from_tag))
continue
modelfile = f"FROM {from_tag}\nPARAMETER num_gpu 0\n"
put_text(cfg, host, "/tmp/gpu-rent-embed.Modelfile", modelfile)
try:
run_ssh(
@@ -1317,20 +1459,30 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
timeout=300,
check=True,
)
roles["memory"] = [
cpu_tag if x == mem or x == base else x for x in roles["memory"]
]
if cpu_tag not in roles["memory"]:
roles["memory"].append(cpu_tag)
put_text(
cfg,
host,
f"{DATA}/Assistent/ollama-roles.json",
json.dumps(roles, indent=2, ensure_ascii=False) + "\n",
)
log(f"Ollama memory CPU model: {cpu_tag} (from {base})")
have.add(cpu_tag)
memory_out.append(cpu_tag)
log(f"Ollama memory CPU model: {cpu_tag} (from {from_tag})")
except Exception as exc:
log(f"⚠ Ollama create {cpu_tag}: {exc}")
memory_out.append(root)
roles["memory"] = finalize_memory_role_tags(memory_out, prefer_cpu=True)
put_text(
cfg,
host,
f"{DATA}/Assistent/ollama-roles.json",
json.dumps(roles, indent=2, ensure_ascii=False) + "\n",
)
log(
f"Assistent ollama-roles: chat={len(roles['chat'])} "
f"memory={roles['memory']}"
)
junk = sorted(t for t in have if is_stacked_cpu_ollama_tag(t))
if junk:
log(
"⚠ Ollama recursive *-cpu tags (можно удалить: ollama rm …): "
+ ", ".join(junk[:6])
+ ("" if len(junk) > 6 else "")
)
warm = preferred_ollama_model(cfg.ollama_models_manifest) or next(
(
n
@@ -1484,6 +1636,10 @@ def provision_vm(
if swarm:
ensure_swarmui_running(cfg, host, log, restart=restart)
try:
verify_assistent_sqlite_bins(cfg, host, log)
except Exception as exc:
log(f"Assistent Sqlite check: {exc}")
run_ssh(
cfg,
host,
+53
View File
@@ -54,3 +54,56 @@ def test_write_preset_includes_memory(tmp_path: Path):
entries = parse_ollama_models(path)
assert preferred_ollama_model(path) is not None
assert preferred_ollama_model(path) != MEMORY_EMBED_MODEL
def test_cpu_ollama_tag_does_not_stack():
from gpu_rent.llm_runtime import (
cpu_ollama_tag,
finalize_memory_role_tags,
is_stacked_cpu_ollama_tag,
ollama_model_root,
pick_ollama_from_tag,
)
assert ollama_model_root("nomic-embed-text-cpu-cpu-cpu") == "nomic-embed-text"
assert cpu_ollama_tag("nomic-embed-text") == "nomic-embed-text-cpu"
assert cpu_ollama_tag("nomic-embed-text-cpu") == "nomic-embed-text-cpu"
assert cpu_ollama_tag("nomic-embed-text-cpu-cpu:latest") == "nomic-embed-text-cpu"
assert is_stacked_cpu_ollama_tag("nomic-embed-text-cpu-cpu")
assert not is_stacked_cpu_ollama_tag("nomic-embed-text-cpu")
have = {
"nomic-embed-text",
"nomic-embed-text-cpu",
"nomic-embed-text-cpu-cpu",
"nomic-embed-text-cpu-cpu-cpu",
}
assert pick_ollama_from_tag(have, "nomic-embed-text") == "nomic-embed-text"
assert pick_ollama_from_tag(have, "nomic-embed-text-cpu") == "nomic-embed-text"
# Re-provision must not FROM a stacked tag
assert pick_ollama_from_tag(
{"nomic-embed-text-cpu-cpu", "nomic-embed-text-cpu"},
"nomic-embed-text-cpu",
) == "nomic-embed-text-cpu"
assert finalize_memory_role_tags(
["nomic-embed-text", "nomic-embed-text-cpu-cpu-cpu"],
prefer_cpu=True,
) == ["nomic-embed-text-cpu"]
assert finalize_memory_role_tags(
["nomic-embed-text-cpu-cpu", "nomic-embed-text"],
prefer_cpu=True,
) == ["nomic-embed-text-cpu"]
def test_roles_payload_collapses_duplicate_memory_roots():
from gpu_rent.llm_runtime import OllamaModelEntry, ollama_roles_payload
roles = ollama_roles_payload(
[
OllamaModelEntry(name="chat:7b", default=True, use="chat"),
OllamaModelEntry(name="nomic-embed-text", use="memory"),
OllamaModelEntry(name="nomic-embed-text-cpu-cpu", use="memory"),
]
)
assert roles["memory"] == ["nomic-embed-text"]