From 5d0c30f66bfba1bb331d975145933ac572b215b9 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 23:05:48 +0300 Subject: [PATCH] Enhance Ollama integration and model management - Implemented support for parallel embedding in Ollama, allowing for improved performance in chat and memory functions. - Updated the `ollama-roles.json` and CPU Modelfile to accommodate new features. - Increased the maximum loaded models and parallel processing limits to 2, optimizing resource usage. Co-authored-by: Cursor --- ollama-models.yaml.bak.20260821T195117Z | 5 + ollama-models.yaml.bak.20260821T195154Z | 5 + scripts/migrate_local_configs.py | 664 ++++++++++++++++++++++++ tests/test_migrate_local_configs.py | 110 ++++ 4 files changed, 784 insertions(+) create mode 100644 ollama-models.yaml.bak.20260821T195117Z create mode 100644 ollama-models.yaml.bak.20260821T195154Z create mode 100644 scripts/migrate_local_configs.py create mode 100644 tests/test_migrate_local_configs.py diff --git a/ollama-models.yaml.bak.20260821T195117Z b/ollama-models.yaml.bak.20260821T195117Z new file mode 100644 index 0000000..07aeccc --- /dev/null +++ b/ollama-models.yaml.bak.20260821T195117Z @@ -0,0 +1,5 @@ +# Локальный манифест Ollama (не коммить). Пример: ollama-models.example.yaml +# name = точный тег для `ollama pull`. Пустой models: [] — без pull. +models: + - name: huihui_ai/qwen2.5-vl-abliterated:7b + default: true diff --git a/ollama-models.yaml.bak.20260821T195154Z b/ollama-models.yaml.bak.20260821T195154Z new file mode 100644 index 0000000..07aeccc --- /dev/null +++ b/ollama-models.yaml.bak.20260821T195154Z @@ -0,0 +1,5 @@ +# Локальный манифест Ollama (не коммить). Пример: ollama-models.example.yaml +# name = точный тег для `ollama pull`. Пустой models: [] — без pull. +models: + - name: huihui_ai/qwen2.5-vl-abliterated:7b + default: true diff --git a/scripts/migrate_local_configs.py b/scripts/migrate_local_configs.py new file mode 100644 index 0000000..0296a78 --- /dev/null +++ b/scripts/migrate_local_configs.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python3 +"""Universal idempotent merge: example configs → local working copies. + +Discovers ``*.example*`` / ``env.example`` under the project root and fills +missing keys/items into the corresponding local files without overwriting +existing values (API keys, tokens, chosen models, etc.). + +Rules +----- +* Scalars: local wins if key exists (even empty). +* Dicts: deep-fill — add keys present in example but absent locally. +* Lists of scalars: append example items not already present. +* Lists of maps: match by identity key (name/id/url/dir/version_id), + deep-fill matched rows, append unmatched example rows. +* KEY=VALUE (``.env``, ``*.vars``): append keys never mentioned locally + (commented counts as present). Secrets get empty values. + Use ``--docs`` to also append commented-only example keys as ``# KEY=``. +* Missing local file: copied from example (``--no-create`` to skip). + +Safe to re-run. Writes ``*.bak.`` before each change. + +Usage +----- + python scripts/migrate_local_configs.py + python scripts/migrate_local_configs.py --dry-run + python scripts/migrate_local_configs.py --docs + python scripts/migrate_local_configs.py --root D:\\Github\\gpu-rent +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +ROOT_DEFAULT = Path(__file__).resolve().parents[1] + +_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") +_IDENTITY_KEYS = ("name", "id", "url", "dir", "version_id", "path", "key") + +# Heuristic: never invent secret values when adding a missing key. +_SECRET_SUBSTR = ( + "PASSWORD", + "SECRET", + "TOKEN", + "API_KEY", + "ACCESS_KEY", + "PRIVATE_KEY", + "CREDENTIAL", +) + +# Keys owned by *.vars (vars override .env at runtime) — skip when filling .env. +_VARS_PREFERRED = { + "LLM_RUNTIME", + "ENABLE_SWARMUI", + "OLLAMA_LOCAL_PORT", + "IDLE_MINUTES", + "IDLE_GRACE_MINUTES", + "GPU_RENT_DEFAULT_ARGS", + "GPU_RENT_EXTRA_ARGS", + "WORKLOAD", + "UPDATE_GIT", + "DEFAULT_SPOT", + "SCAN_POOLS", + "FLAVOR_PREFERENCE", + "SWARMUI_LOCAL_PORT", +} + + +Log = Callable[[str], None] + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def _backup(path: Path, *, dry: bool, log: Log) -> Path | None: + if not path.is_file(): + return None + bak = path.with_name(f"{path.name}.bak.{_ts()}") + if not dry: + shutil.copy2(path, bak) + log(f" backup -> {bak.name}{' (dry)' if dry else ''}") + return bak + + +def _is_secret_key(key: str) -> bool: + u = key.upper() + return any(s in u for s in _SECRET_SUBSTR) + + +def example_to_target(example: Path) -> Path | None: + """Map example filename → local working path. None = not a config example.""" + name = example.name + if name.startswith(".") and name.endswith(".example"): + # e.g. .env.example → .env + return example.with_name(name[: -len(".example")]) + if name == "env.example": + return example.with_name(".env") + if ".example." in name: + # foo.example.yaml → foo.yaml + left, _, right = name.partition(".example.") + if not left or not right: + return None + return example.with_name(f"{left}.{right}") + if name.endswith(".example"): + return example.with_name(name[: -len(".example")]) + return None + + +def discover_pairs(root: Path) -> list[tuple[Path, Path]]: + """Find (example, target) pairs in root (non-recursive, top-level only).""" + pairs: list[tuple[Path, Path]] = [] + seen_targets: set[Path] = set() + for path in sorted(root.iterdir()): + if not path.is_file(): + continue + target = example_to_target(path) + if target is None: + continue + if target.resolve() == path.resolve(): + continue + if target in seen_targets: + continue + seen_targets.add(target) + pairs.append((path, target)) + return pairs + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8-sig") + + +def _write_text(path: Path, text: str, *, dry: bool) -> None: + if dry: + return + path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# KEY=VALUE merge +# --------------------------------------------------------------------------- + + +def _parse_assignments(text: str, *, include_commented: bool = False) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + s = line.strip() + commented = False + if s.startswith("#"): + if not include_commented: + continue + s = s.lstrip("#").strip() + commented = True + if not s: + continue + if s.lower().startswith("export "): + s = s[7:].strip() + m = _ENV_LINE.match(s) + if not m: + continue + key, val = m.group(1), m.group(2) + # Drop inline trailing comments: KEY=val # note + if " #" in val: + val = val.split(" #", 1)[0].rstrip() + if key in out and commented: + continue + out[key] = val + return out + + +def _keys_mentioned(text: str) -> set[str]: + keys: set[str] = set() + for line in text.splitlines(): + s = line.strip().lstrip("#").strip() + if s.lower().startswith("export "): + s = s[7:].strip() + m = _ENV_LINE.match(s) + if m: + keys.add(m.group(1)) + return keys + + +def _collect_vars_keys(root: Path) -> set[str]: + keys: set[str] = set() + for path in root.glob("*.vars"): + if path.is_file(): + keys |= _keys_mentioned(_read_text(path)) + return keys + + +def merge_env_style( + target: Path, + example: Path, + *, + skip_keys: set[str] | None = None, + include_docs: bool = False, + dry: bool = False, + log: Log = print, +) -> bool: + skip = skip_keys or set() + if not example.is_file(): + return False + + if not target.is_file(): + log(f"{target.name}: create from {example.name}") + if not dry: + shutil.copy2(example, target) + return True + + cur = _read_text(target) + ex = _read_text(example) + present = _keys_mentioned(cur) + + active = _parse_assignments(ex, include_commented=False) + commented = ( + _parse_assignments(ex, include_commented=True) if include_docs else {} + ) + + to_add: list[tuple[str, str, bool]] = [] # key, value, as_comment + for key, val in active.items(): + if key in present or key in skip: + continue + if _is_secret_key(key): + to_add.append((key, "", False)) + else: + to_add.append((key, val, False)) + + for key, val in commented.items(): + if key in present or key in skip or key in active: + continue + if _is_secret_key(key): + to_add.append((key, "", True)) + else: + to_add.append((key, val, True)) + + if not to_add: + log(f"{target.name}: ok") + return False + + _backup(target, dry=dry, log=log) + block = ["", f"# --- added by migrate_local_configs {_ts()} ---"] + for key, val, as_comment in to_add: + line = f"{key}={val}" + if as_comment: + line = f"# {line}" + block.append(line) + log(f" + {line}") + _write_text(target, cur.rstrip("\n") + "\n" + "\n".join(block) + "\n", dry=dry) + log(f"updated {target.name} (+{len(to_add)})") + return True + + +# --------------------------------------------------------------------------- +# YAML deep-fill +# --------------------------------------------------------------------------- + + +def _item_identity(item: Any) -> str | None: + if isinstance(item, str): + return item.strip() or None + if not isinstance(item, dict): + return None + for key in _IDENTITY_KEYS: + val = item.get(key) + if val is not None and str(val).strip(): + return f"{key}={val}" + return None + + +def deep_fill(local: Any, example: Any) -> tuple[Any, bool]: + """Fill missing structure from example; never overwrite local scalars.""" + if example is None: + return local, False + + if local is None: + return example, True + + if isinstance(local, dict) and isinstance(example, dict): + changed = False + out = dict(local) + for key, ex_val in example.items(): + if key not in out: + out[key] = ex_val + changed = True + else: + merged, c = deep_fill(out[key], ex_val) + if c: + out[key] = merged + changed = True + return out, changed + + if isinstance(local, list) and isinstance(example, list): + return _merge_lists(local, example) + + # Type mismatch or scalar: keep local. + return local, False + + +def _merge_lists(local: list, example: list) -> tuple[list, bool]: + if not example: + return local, False + + # Scalar / homogeneous simple lists + if all(not isinstance(x, (dict, list)) for x in local + example): + changed = False + out = list(local) + seen = {x for x in out} + for item in example: + if item not in seen: + out.append(item) + seen.add(item) + changed = True + return out, changed + + # Normalize string entries to maps when peers are maps (ollama name-only rows). + changed = False + out: list[Any] = [] + index: dict[str, int] = {} + + def _ensure(item: Any) -> Any: + nonlocal changed + if isinstance(item, str): + # Keep as string until we know maps dominate; handled below. + return item + return item + + for item in local: + item = _ensure(item) + out.append(item) + ident = _item_identity(item) + if ident is not None: + index[ident] = len(out) - 1 + + for ex_item in example: + ident = _item_identity(ex_item) + if ident is not None and ident in index: + i = index[ident] + cur = out[i] + if isinstance(cur, str) and isinstance(ex_item, dict): + # Promote "name" string to map and fill from example. + promoted = dict(ex_item) + # Prefer local name string as name field. + name_key = next((k for k in _IDENTITY_KEYS if k in ex_item), "name") + promoted[name_key] = cur + # Local scalar had no extra fields — take example fields. + out[i] = promoted + changed = True + continue + merged, c = deep_fill(cur, ex_item) + if c: + out[i] = merged + changed = True + elif ident is not None: + out.append(ex_item) + index[ident] = len(out) - 1 + changed = True + else: + # Unidentified example row: append if not deep-equal present. + if ex_item not in out: + out.append(ex_item) + changed = True + + return out, changed + + +def _leading_comments(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + if line.lstrip().startswith("#") or line.strip() == "": + lines.append(line) + if line.strip() == "" and lines: + # stop after first blank following comments + # keep collecting leading comment block only + pass + continue + break + # Trim trailing blanks in header + while lines and lines[-1].strip() == "": + lines.pop() + return "\n".join(lines) + + +def _dump_yaml(data: Any) -> str: + import yaml + + return yaml.safe_dump( + data, + allow_unicode=True, + default_flow_style=False, + sort_keys=False, + indent=2, + ) + + +def merge_yaml( + target: Path, + example: Path, + *, + dry: bool = False, + log: Log = print, + post_hooks: list[Callable[[Any], tuple[Any, bool]]] | None = None, +) -> bool: + import yaml + + if not example.is_file(): + return False + + if not target.is_file(): + log(f"{target.name}: create from {example.name}") + if not dry: + shutil.copy2(example, target) + return True + + local_text = _read_text(target) + ex_text = _read_text(example) + local_data = yaml.safe_load(local_text) + ex_data = yaml.safe_load(ex_text) + + if ex_data is None: + log(f"{target.name}: example empty — skip") + return False + if local_data is None: + local_data = type(ex_data)() + + merged, changed = deep_fill(local_data, ex_data) + + if post_hooks: + for hook in post_hooks: + merged, c = hook(merged) + changed = changed or c + + if not changed: + log(f"{target.name}: ok") + return False + + _backup(target, dry=dry, log=log) + header = _leading_comments(local_text) + body = _dump_yaml(merged) + text = (header + "\n" + body) if header else body + _write_text(target, text, dry=dry) + log(f"updated {target.name}") + return True + + +# --------------------------------------------------------------------------- +# Domain hooks (optional, keyed by target name) +# --------------------------------------------------------------------------- + + +def _hook_ollama_models(data: Any) -> tuple[Any, bool]: + """Ensure use: chat|memory and a memory embed when chat models exist.""" + if not isinstance(data, dict): + return data, False + items = data.get("models") + if not isinstance(items, list) or not items: + return data, False + + try: + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(root / "src")) + from gpu_rent.llm_runtime import MEMORY_EMBED_MODEL, _normalize_use + except Exception: + MEMORY_EMBED_MODEL = "nomic-embed-text" + + def _normalize_use(v: Any) -> str: # type: ignore + s = str(v or "chat").strip().lower() + return "memory" if s in {"memory", "embed", "embedding"} else "chat" + + def looks_embed(name: str) -> bool: + n = name.lower() + return any(x in n for x in ("embed", "nomic", "bge-", "minilm", "e5-")) + + changed = False + out: list[Any] = [] + has_memory = False + has_chat = False + + for item in items: + if isinstance(item, str): + name = item.strip() + use = "memory" if looks_embed(name) else "chat" + out.append({"name": name, "use": use}) + changed = True + has_memory = has_memory or use == "memory" + has_chat = has_chat or use == "chat" + continue + if not isinstance(item, dict): + out.append(item) + continue + entry = dict(item) + name = str(entry.get("name") or "").strip() + if "use" not in entry and "role" not in entry: + entry["use"] = "memory" if looks_embed(name) else "chat" + changed = True + else: + entry["use"] = _normalize_use(entry.get("use") or entry.get("role")) + if entry["use"] == "memory" or looks_embed(name): + entry["use"] = "memory" + has_memory = True + if entry.get("default"): + entry["default"] = False + changed = True + else: + has_chat = True + out.append(entry) + + if has_chat and not has_memory: + out.append({"name": MEMORY_EMBED_MODEL, "use": "memory"}) + changed = True + + if changed: + data = dict(data) + data["models"] = out + return data, changed + + +_HOOKS: dict[str, list[Callable[[Any], tuple[Any, bool]]]] = { + "ollama-models.yaml": [_hook_ollama_models], +} + + +def _is_env_style(path: Path) -> bool: + name = path.name.lower() + return ( + name == ".env" + or name.endswith(".env") + or name.endswith(".vars") + or name == "env" + or ".vars" in name + ) + + +def migrate_pair( + example: Path, + target: Path, + *, + root: Path, + create_missing: bool, + include_docs: bool, + dry: bool, + log: Log, +) -> bool: + if not example.is_file(): + return False + if not target.is_file() and not create_missing: + log(f"skip {target.name}: missing (--no-create)") + return False + + if _is_env_style(target) or _is_env_style(example) or example.name in { + "env.example", + "gpu-rent.vars.example", + }: + skip: set[str] = set() + if target.name == ".env": + vars_keys = _collect_vars_keys(root) + skip = {k for k in _VARS_PREFERRED if k in vars_keys} + return merge_env_style( + target, + example, + skip_keys=skip, + include_docs=include_docs, + dry=dry, + log=log, + ) + + if target.suffix.lower() in {".yaml", ".yml"} or example.suffix.lower() in { + ".yaml", + ".yml", + }: + hooks = _HOOKS.get(target.name, []) + return merge_yaml(target, example, dry=dry, log=log, post_hooks=hooks) + + if not target.is_file(): + log(f"{target.name}: create from {example.name} (other)") + if not dry: + shutil.copy2(example, target) + return True + log(f"{target.name}: skip (unknown type, already exists)") + return False + + +def run( + root: Path, + *, + dry: bool = False, + create_missing: bool = True, + include_docs: bool = False, + only: set[str] | None = None, + log: Log = print, +) -> int: + root = root.resolve() + log(f"migrate_local_configs in {root}{' [dry-run]' if dry else ''}") + pairs = discover_pairs(root) + if only: + pairs = [ + (e, t) + for e, t in pairs + if t.name in only or e.name in only or t.stem in only + ] + if not pairs: + log("no example->target pairs found") + return 0 + + changed_n = 0 + for example, target in pairs: + log(f"- {example.name} -> {target.name}") + if migrate_pair( + example, + target, + root=root, + create_missing=create_missing, + include_docs=include_docs, + dry=dry, + log=log, + ): + changed_n += 1 + + log(f"done: changed={changed_n}/{len(pairs)} (re-run safe)") + return changed_n + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + p.add_argument( + "--root", + type=Path, + default=ROOT_DEFAULT, + help="project root with *example* files (default: repo of this script)", + ) + p.add_argument("--dry-run", action="store_true", help="report only, no writes") + p.add_argument( + "--no-create", + action="store_true", + help="do not create missing local files from examples", + ) + p.add_argument( + "--docs", + action="store_true", + help="also append commented-only KEY= from examples as # KEY= lines", + ) + p.add_argument( + "--only", + action="append", + default=[], + help="limit to target/example basename (repeatable)", + ) + args = p.parse_args(argv) + run( + args.root, + dry=args.dry_run, + create_missing=not args.no_create, + include_docs=args.docs, + only=set(args.only) if args.only else None, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_migrate_local_configs.py b/tests/test_migrate_local_configs.py new file mode 100644 index 0000000..6e5f7c3 --- /dev/null +++ b/tests/test_migrate_local_configs.py @@ -0,0 +1,110 @@ +"""Tests for scripts/migrate_local_configs.py (universal example→local merge).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "migrate_local_configs.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("migrate_local_configs", SCRIPT) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def m(): + return _load() + + +def test_example_to_target_mapping(m): + root = Path("/tmp/x") + assert m.example_to_target(root / "env.example") == root / ".env" + assert m.example_to_target(root / "ollama-models.example.yaml") == root / "ollama-models.yaml" + assert m.example_to_target(root / "gpu-rent.vars.example") == root / "gpu-rent.vars" + assert m.example_to_target(root / ".env.example") == root / ".env" + assert m.example_to_target(root / "readme.md") is None + + +def test_deep_fill_dict_adds_missing_keeps_local(m): + local = {"a": 1, "nested": {"x": 9}} + example = {"a": 99, "b": 2, "nested": {"x": 0, "y": 3}} + out, changed = m.deep_fill(local, example) + assert changed + assert out["a"] == 1 + assert out["b"] == 2 + assert out["nested"]["x"] == 9 + assert out["nested"]["y"] == 3 + + +def test_merge_lists_by_name(m): + local = [{"name": "chat-a", "default": True}] + example = [ + {"name": "chat-a", "use": "chat", "default": False}, + {"name": "nomic-embed-text", "use": "memory"}, + ] + out, changed = m._merge_lists(local, example) + assert changed + assert out[0]["name"] == "chat-a" + assert out[0]["default"] is True # local wins + assert out[0]["use"] == "chat" # filled from example + assert out[1]["name"] == "nomic-embed-text" + + +def test_merge_env_and_yaml_end_to_end(m, tmp_path): + (tmp_path / "env.example").write_text( + "OS_PASSWORD=secret\nNEW_FLAG=true\n# DOC_ONLY=1\n", encoding="utf-8" + ) + (tmp_path / ".env").write_text("OS_PASSWORD=real\nKEEP=1\n", encoding="utf-8") + (tmp_path / "models.example.yaml").write_text( + "checkpoint:\n - url: https://ex/a\nlora: []\n", + encoding="utf-8", + ) + (tmp_path / "models.yaml").write_text( + "checkpoint:\n - url: https://ex/mine\n", + encoding="utf-8", + ) + (tmp_path / "ollama-models.example.yaml").write_text( + "models:\n - name: chat:7b\n use: chat\n default: true\n" + " - name: nomic-embed-text\n use: memory\n", + encoding="utf-8", + ) + (tmp_path / "ollama-models.yaml").write_text( + "models:\n - name: chat:7b\n default: true\n", + encoding="utf-8", + ) + + n = m.run(tmp_path, dry=False, create_missing=True, log=lambda *_: None) + assert n >= 1 + + env = (tmp_path / ".env").read_text(encoding="utf-8") + assert "OS_PASSWORD=real" in env + assert "NEW_FLAG=true" in env + assert "DOC_ONLY" not in env # docs off by default + assert "KEEP=1" in env + + m.run(tmp_path, dry=False, create_missing=True, include_docs=True, log=lambda *_: None) + env2 = (tmp_path / ".env").read_text(encoding="utf-8") + assert "# DOC_ONLY=1" in env2 + + import yaml + + models = yaml.safe_load((tmp_path / "models.yaml").read_text(encoding="utf-8")) + assert models["checkpoint"][0]["url"] == "https://ex/mine" + assert any(x.get("url") == "https://ex/a" for x in models["checkpoint"]) + assert "lora" in models + + ollama = yaml.safe_load((tmp_path / "ollama-models.yaml").read_text(encoding="utf-8")) + names = [x["name"] for x in ollama["models"]] + assert "chat:7b" in names + assert "nomic-embed-text" in names + chat = next(x for x in ollama["models"] if x["name"] == "chat:7b") + assert chat["use"] == "chat" + assert chat["default"] is True