From ca11467a0d40021e8bc6dea26dbded476b3a0f8c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 06:45:04 +0300 Subject: [PATCH] Stop stacking Ollama -cpu embed tags and verify Assistent Sqlite after seed. Co-authored-by: Cursor --- docs/extensions.md | 2 +- src/gpu_rent/cli.py | 4 + src/gpu_rent/debug_assistent.py | 32 ++++- src/gpu_rent/llm_runtime.py | 95 +++++++++++++- src/gpu_rent/provision.py | 224 +++++++++++++++++++++++++++----- tests/test_ollama_model_use.py | 53 ++++++++ 6 files changed, 367 insertions(+), 43 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index b244af3..c65296c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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//*.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//*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся. В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера. diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 53006f3..7e0a87f 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -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) diff --git a/src/gpu_rent/debug_assistent.py b/src/gpu_rent/debug_assistent.py index a565540..f04f49e 100644 --- a/src/gpu_rent/debug_assistent.py +++ b/src/gpu_rent/debug_assistent.py @@ -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": { diff --git a/src/gpu_rent/llm_runtime.py b/src/gpu_rent/llm_runtime.py index 1277ee7..e834dd6 100644 --- a/src/gpu_rent/llm_runtime.py +++ b/src/gpu_rent/llm_runtime.py @@ -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] diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index 67311fc..25af499 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -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, diff --git a/tests/test_ollama_model_use.py b/tests/test_ollama_model_use.py index 212da2e..51e5f81 100644 --- a/tests/test_ollama_model_use.py +++ b/tests/test_ollama_model_use.py @@ -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"]