Files
alisson-anjos-ComfyUI-BFSNodes/bfs_lora_surgery.py
alisson-anjosandClaude Opus 5 5c8c41abbd LoRA Surgery: carry a recipe to the save node without copying JSON (1.45.0)
Getting a recipe from the interactive node into the save node meant copying the rules JSON
between two text widgets, which is a bad way to move state and easy to get wrong.

Two paths now. BFS LoRA Surgery gained a `rules` output that can be wired straight into the
save node's `rules` input, so the file is produced as part of the run. And the panel has a
"save as file" button with an optional name, which posts to a new /bfs/lora/save route and
reports the result inline, for when you are still exploring and do not want a second node in
the graph.

Both go through one write_surgery function, so the two paths cannot drift apart. That
function is the old node body lifted out, not a copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 12:33:29 +00:00

692 lines
27 KiB
Python

"""BFS LoRA Surgery: read any LoRA's layers and blocks, then scale or drop them.
Model-agnostic: the structure is discovered from the key names, not hardcoded. Works with
the diffusers style (``lora_A``/``lora_B``), the kohya style (``lora_down``/``lora_up`` plus
``alpha``) and anything that follows either convention.
Why this exists: a defect a LoRA learned (smoothed skin, identity drifting at distance, a
pose it refuses to copy) usually lives in a specific module family and a specific range of
blocks. Scaling or dropping that group and regenerating tells you where it lives, in minutes,
without retraining. See the node's tooltip for the workflow.
Scaling a module by ``s`` multiplies only the up/B factor, since ``(sB)A = s(BA)``. That keeps
negative values usable and is exact regardless of the alpha convention.
"""
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime, timezone
import re
import struct
from typing import Any
import torch
import comfy.sd
import comfy.utils
import folder_paths
# ---------------------------------------------------------------------------- parsing
_SUFFIXES = (
".lora_down.weight", ".lora_up.weight",
".lora_A.weight", ".lora_B.weight",
".lora_A.default.weight", ".lora_B.default.weight",
".hada_w1_a", ".hada_w1_b", ".hada_w2_a", ".hada_w2_b",
".lokr_w1", ".lokr_w2", ".lokr_w1_a", ".lokr_w1_b", ".lokr_w2_a", ".lokr_w2_b",
".diff", ".diff_b", ".alpha", ".dora_scale",
)
_UP_SUFFIXES = (".lora_up.weight", ".lora_B.weight", ".lora_B.default.weight")
_BLOCK_RE = re.compile(r"(?<=[._])(\d+)(?=[._])")
def _strip_suffix(key: str) -> str | None:
for s in _SUFFIXES:
if key.endswith(s):
return key[: -len(s)]
return None
_NUM_RE = re.compile(r"\d+")
def _template(path: str) -> tuple[str, list[int], list[tuple[int, int]]]:
"""``a.12.attn1.q`` -> ("a.{}.attn{}.q", [12, 1], spans)."""
nums, spans, out, last = [], [], [], 0
for m in _NUM_RE.finditer(path):
out.append(path[last:m.start()])
out.append("{}")
nums.append(int(m.group()))
spans.append((m.start(), m.end()))
last = m.end()
out.append(path[last:])
return "".join(out), nums, spans
def choose_block_axis(paths: list[str]) -> dict[str, int]:
"""For each path template, decide which numeric slot is the block index.
Picking the last number breaks on names like ``transformer_blocks.12.attn1.to_q``, where
the last one is the ``1`` of ``attn1``, and on nested layouts such as
``down_blocks.0.attentions.1.transformer_blocks.0.ff.net.0.proj``. The block axis is the
slot that varies most across the whole LoRA, so decide it from the file, not from one key.
"""
seen: dict[str, list[set]] = {}
for path in paths:
tpl, nums, _ = _template(path)
slots = seen.setdefault(tpl, [set() for _ in nums])
for i, v in enumerate(nums):
if i < len(slots):
slots[i].add(v)
axis: dict[str, int] = {}
for tpl, slots in seen.items():
if not slots:
continue
counts = [len(x) for x in slots]
best = max(range(len(counts)), key=lambda i: (counts[i], -i))
axis[tpl] = best if counts[best] > 1 else -1
return axis
def _split_block(path: str, axis: dict[str, int] | None = None) -> tuple[str, int | None, str]:
"""Return (family, block_index, module_type).
``transformer_blocks.12.attn.to_q`` -> ("transformer_blocks", 12, "attn.to_q").
Numbers that are not the block axis stay in the name, so ``attn1`` and ``attn2`` remain
distinct module types instead of being folded together.
"""
tpl, nums, spans = _template(path)
if not nums:
return path, None, ""
idx = axis.get(tpl, -1) if axis is not None else (len(nums) - 1)
if idx < 0 or idx >= len(nums):
return path.rstrip("._"), None, ""
start, end = spans[idx]
family = path[:start].rstrip("._")
module = path[end:].lstrip("._")
return family, nums[idx], module
def read_structure(lora_path: str, with_norms: bool = True) -> dict[str, Any]:
"""Describe a LoRA: families, block indices, module types, ranks and ΔW norms.
Reads the safetensors header first (instant) and only loads tensors when norms are asked
for. Norms are what tell you where training actually invested.
"""
with open(lora_path, "rb") as fh:
n = struct.unpack("<Q", fh.read(8))[0]
header = json.loads(fh.read(n))
header.pop("__metadata__", None)
modules: dict[str, dict[str, Any]] = {}
for key, info in header.items():
base = _strip_suffix(key)
if base is None:
continue
entry = modules.setdefault(base, {"rank": None, "keys": []})
entry["keys"].append(key)
shape = info.get("shape") or []
if len(shape) == 2 and entry["rank"] is None:
entry["rank"] = int(min(shape))
norms: dict[str, float] = {}
if with_norms and modules:
norms = _module_norms(lora_path, modules)
axis = choose_block_axis(list(modules))
families: dict[str, dict[str, Any]] = {}
loose: list[dict[str, Any]] = []
all_modules: list[dict[str, Any]] = []
for base, entry in modules.items():
family, block, mtype = _split_block(base, axis)
all_modules.append({"path": base, "family": family, "block": block, "type": mtype,
"rank": entry["rank"], "norm": norms.get(base)})
if block is None:
loose.append({"path": base, "rank": entry["rank"], "norm": norms.get(base)})
continue
fam = families.setdefault(family, {"name": family, "blocks": set(), "types": {}})
fam["blocks"].add(block)
t = fam["types"].setdefault(mtype, {"name": mtype, "count": 0, "rank": entry["rank"],
"norm_sum": 0.0, "norm_n": 0, "blocks": []})
t["count"] += 1
t["blocks"].append(block)
if base in norms:
t["norm_sum"] += norms[base]
t["norm_n"] += 1
out_families = []
for fam in families.values():
types = []
for t in fam["types"].values():
types.append({
"name": t["name"], "count": t["count"], "rank": t["rank"],
"norm": (t["norm_sum"] / t["norm_n"]) if t["norm_n"] else None,
"blocks": sorted(t["blocks"]),
})
types.sort(key=lambda x: -(x["norm"] or 0))
out_families.append({
"name": fam["name"],
"blocks": sorted(fam["blocks"]),
"types": types,
})
out_families.sort(key=lambda f: -sum(t["count"] for t in f["types"]))
all_modules.sort(key=lambda m: (m["family"], m["block"] if m["block"] is not None else -1, m["type"]))
return {
"file": os.path.basename(lora_path),
"total_modules": len(modules),
"families": out_families,
"loose": sorted(loose, key=lambda x: x["path"])[:64],
"modules": all_modules,
"per_module_norms": norms,
}
def _module_norms(lora_path: str, modules: dict[str, dict[str, Any]]) -> dict[str, float]:
"""Exact ||ΔW||_F per module.
ΔW = B·A has rank <= r, so QR both factors and take the singular values of the small
r x r product. No need to materialize the full d x d matrix.
"""
try:
sd = comfy.utils.load_torch_file(lora_path, safe_load=True)
except Exception:
return {}
out: dict[str, float] = {}
for base in modules:
down = sd.get(base + ".lora_down.weight", sd.get(base + ".lora_A.weight"))
up = sd.get(base + ".lora_up.weight", sd.get(base + ".lora_B.weight"))
if down is None or up is None or down.ndim != 2 or up.ndim != 2:
continue
try:
a = down.float()
b = up.float()
scale = 1.0
alpha = sd.get(base + ".alpha")
if alpha is not None:
scale = float(alpha) / a.shape[0]
qb, rb = torch.linalg.qr(b)
qa, ra = torch.linalg.qr(a.T)
s = torch.linalg.svdvals(rb @ ra.T)
out[base] = float(s.pow(2).sum().sqrt()) * scale
except Exception:
continue
del sd
return out
# ---------------------------------------------------------------------------- rules
def _match_blocks(spec: str | None, block: int) -> bool:
"""``spec`` is a block selector: "" / "all" / "8-15" / "0,4,7" / "8-15,24-31"."""
if not spec or spec.strip().lower() in ("all", "*"):
return True
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
lo, _, hi = part.partition("-")
try:
if int(lo) <= block <= int(hi):
return True
except ValueError:
continue
else:
try:
if int(part) == block:
return True
except ValueError:
continue
return False
def build_regex(family: str | None, mtype: str | None, blocks: str | None) -> str:
r"""Build the regex a selection stands for. This is what the UI's "to regex" button calls.
Selecting family ``transformer_blocks``, type ``img_mlp.gate_up`` and blocks ``8-15``
gives ``^.*transformer_blocks\.(8|9|10|11|12|13|14|15)\.img_mlp\.gate_up$``.
Leaving blocks empty matches every block.
"""
fam = re.escape(family) if family else r"[\\w.]+"
if blocks and blocks.strip().lower() not in ("all", "*", ""):
idx: list[int] = []
for part in blocks.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
lo, _, hi = part.partition("-")
try:
idx.extend(range(int(lo), int(hi) + 1))
except ValueError:
continue
else:
try:
idx.append(int(part))
except ValueError:
continue
blk = "(" + "|".join(str(i) for i in sorted(set(idx))) + ")" if idx else r"\\d+"
else:
blk = r"\\d+"
if not mtype or mtype.strip() in ("*", ""):
mod = r".*"
elif mtype.endswith("*"):
mod = re.escape(mtype[:-1]) + r".*"
else:
mod = re.escape(mtype)
return rf"^.*{fam}\.{blk}\.{mod}$"
def _match_type(spec: str | None, mtype: str) -> bool:
if not spec or spec.strip() in ("*", ""):
return True
spec = spec.strip()
if spec.endswith("*"):
return mtype.startswith(spec[:-1])
return mtype == spec
_REGEX_CACHE: dict[str, Any] = {}
def _compiled(pattern: str):
rx = _REGEX_CACHE.get(pattern)
if rx is None:
try:
rx = re.compile(pattern)
except re.error:
rx = False # invalid pattern matches nothing, and never raises mid-run
_REGEX_CACHE[pattern] = rx
return rx
def resolve_scale(rules: list[dict[str, Any]], family: str, block: int | None, mtype: str,
full_path: str | None = None) -> float:
"""Last matching rule wins, which is what makes the UI predictable.
A rule matches either structurally (family / type / blocks) or by ``regex`` against the
module's full path. A rule carrying a regex ignores the structural fields.
"""
scale = 1.0
for rule in rules:
if not rule.get("enabled", True):
continue
m = rule.get("match", {})
pattern = m.get("regex")
if pattern:
rx = _compiled(pattern)
if not rx or full_path is None or not rx.search(full_path):
continue
try:
scale = float(rule.get("scale", 1.0))
except (TypeError, ValueError):
pass
continue
if m.get("family") and m["family"] != family:
continue
if not _match_type(m.get("type"), mtype):
continue
if block is not None and not _match_blocks(m.get("blocks"), block):
continue
if block is None and m.get("blocks"):
continue
try:
scale = float(rule.get("scale", 1.0))
except (TypeError, ValueError):
continue
return scale
def apply_rules(sd: dict[str, torch.Tensor], rules: list[dict[str, Any]]) -> tuple[dict, dict]:
"""Return (new state dict, stats). Scale 0 drops the module entirely."""
if not rules:
return sd, {"kept": None, "dropped": 0, "scaled": 0}
bases = {b for b in (_strip_suffix(k) for k in sd) if b}
axis = choose_block_axis(sorted(bases))
out: dict[str, torch.Tensor] = {}
dropped = scaled = kept = 0
seen: dict[str, float] = {}
for key, tensor in sd.items():
base = _strip_suffix(key)
if base is None:
out[key] = tensor
continue
if base not in seen:
family, block, mtype = _split_block(base, axis)
seen[base] = resolve_scale(rules, family, block, mtype, full_path=base)
s = seen[base]
if s == 0.0:
dropped += 1 if key.endswith(_UP_SUFFIXES) else 0
continue
if s != 1.0 and key.endswith(_UP_SUFFIXES):
# (sB)A == s(BA): exact, and negative values stay usable
out[key] = tensor.float().mul(s).to(tensor.dtype)
scaled += 1
else:
out[key] = tensor
if key.endswith(_UP_SUFFIXES):
kept += 1
return out, {"kept": kept, "dropped": dropped, "scaled": scaled}
# ---------------------------------------------------------------------------- node
class BFSLoraSurgery:
"""Load a LoRA, scale or drop parts of it, and apply the result to the model.
Nothing is written to disk: the edited LoRA lives only in this run, so you can move a
slider and re-queue. Use the panel to pick a module family, a block range and a scale,
or write a regex when you want something the tree cannot express.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength_model": ("FLOAT", {"default": 1.0, "min": -20.0, "max": 20.0,
"step": 0.01, "tooltip": "Overall LoRA strength, as usual."}),
"rules": ("STRING", {"default": "[]", "multiline": True,
"tooltip": "JSON list of rules. The panel writes this for you."}),
},
"optional": {"clip": ("CLIP",)},
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("model", "clip", "report", "rules")
FUNCTION = "apply"
CATEGORY = "BFS/lora"
DESCRIPTION = ("Scale or drop a LoRA's module groups (attention, MLP, per block range) and "
"apply it live. Finds which layers carry a defect without retraining.")
def apply(self, model, lora_name, strength_model, rules, clip=None):
lora_path = folder_paths.get_full_path("loras", lora_name)
if lora_path is None:
raise ValueError(f"LoRA not found: {lora_name}")
sd = comfy.utils.load_torch_file(lora_path, safe_load=True)
parsed: list[dict[str, Any]] = []
if rules and rules.strip():
try:
loaded = json.loads(rules)
if isinstance(loaded, dict):
loaded = loaded.get("rules", [])
if isinstance(loaded, list):
parsed = loaded
except json.JSONDecodeError as exc:
raise ValueError(f"'rules' is not valid JSON: {exc}") from exc
edited, stats = apply_rules(sd, parsed)
total = sum(1 for k in sd if k.endswith(_UP_SUFFIXES))
kept = stats["kept"] if stats["kept"] is not None else total
report = (f"{lora_name}: {kept}/{total} modules kept, {stats['dropped']} dropped, "
f"{stats['scaled']} scaled, {len(parsed)} rule(s), strength {strength_model}")
print(f"[BFSNodes] LoRA surgery, {report}")
new_model, new_clip = comfy.sd.load_lora_for_models(
model, clip, edited, strength_model, strength_model if clip is not None else 0.0)
# `rules` comes back out so it can feed BFS LoRA Surgery (save) without copying JSON
return (new_model, new_clip if clip is not None else clip, report, rules)
def describe_rules(rules: list[dict[str, Any]]) -> str:
"""Short, readable slug for a rule list, used when no filename is given.
``gate_up-b8_15-off__all-b16_23-off__attn-x1.15``. A file named after what was done to it
beats one named v2_final_real.
"""
parts: list[str] = []
for rule in rules:
if not rule.get("enabled", True):
continue
m = rule.get("match", {})
try:
scale = float(rule.get("scale", 1.0))
except (TypeError, ValueError):
scale = 1.0
if m.get("regex"):
what = "re" + hashlib.sha1(m["regex"].encode()).hexdigest()[:6]
else:
core = (m.get("type") or "").replace("*", "").strip(".")
what = re.sub(r"[^A-Za-z0-9_-]", "", core.split(".")[-1]) or "all"
blocks = re.sub(r"[^0-9,_-]", "", (m.get("blocks") or "")).replace(",", "_").replace("-", "_")
tag = what if not blocks else f"{what}-b{blocks}"
parts.append(f"{tag}-{'off' if scale == 0 else f'x{scale:g}'}")
slug = "__".join(parts) if parts else "unchanged"
return re.sub(r"[^A-Za-z0-9._-]", "", slug)
# Linux caps a single filename at 255 bytes (NAME_MAX); Windows caps the whole path near 260
# unless long paths are enabled. Stay well under both, since the folder above us is not ours.
MAX_FILENAME = 160
def fit_filename(stem: str, slug: str, ext: str = ".safetensors", limit: int = MAX_FILENAME) -> str:
"""Build ``stem__slug.ext`` and shrink it to ``limit`` bytes without losing uniqueness.
When it does not fit, both halves are trimmed and a short hash of the full slug is
appended, so two different recipes never collapse onto the same name.
"""
name = f"{stem}__{slug}{ext}"
if len(name.encode("utf-8")) <= limit:
return name
digest = hashlib.sha1(f"{stem}__{slug}".encode()).hexdigest()[:8]
room = limit - len(ext) - len(digest) - 3 # "__" between halves, "-" before hash
keep_stem = min(len(stem), max(16, room // 2))
keep_slug = max(8, room - keep_stem)
return f"{stem[:keep_stem]}__{slug[:keep_slug]}-{digest}{ext}"
def summarize_rules(rules: list[dict[str, Any]]) -> str:
"""One sentence a human can read months later, without parsing JSON."""
drops, scales = [], []
for rule in rules:
if not rule.get("enabled", True):
continue
m = rule.get("match", {})
try:
scale = float(rule.get("scale", 1.0))
except (TypeError, ValueError):
continue
if m.get("regex"):
what = f"modules matching /{m['regex']}/"
else:
t = m.get("type") or "*"
what = "all modules" if t in ("*", "") else t
blocks = m.get("blocks")
if blocks and blocks.strip().lower() not in ("all", "*"):
what += f" in blocks {blocks}"
if scale == 0.0:
drops.append(what)
elif scale != 1.0:
scales.append(f"{what} x{scale:g}")
bits = []
if drops:
bits.append("dropped " + "; ".join(drops))
if scales:
bits.append("scaled " + "; ".join(scales))
return ", ".join(bits) if bits else "no changes"
def _file_digest(path: str) -> str:
"""sha256 of the source, so provenance survives a rename."""
h = hashlib.sha256()
try:
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(4 << 20), b""):
h.update(chunk)
return h.hexdigest()
except Exception: # noqa: BLE001
return ""
class BFSLoraSurgerySave:
"""Write the edited LoRA to models/loras as a real file.
Same rules as BFS LoRA Surgery. Leave ``filename`` empty and the name is built from the
rules themselves, so the file says what was done to it. The recipe is also stored in the
safetensors metadata under ``bfs_surgery``, which survives being shared.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_name": (folder_paths.get_filename_list("loras"),),
"rules": ("STRING", {"default": "[]", "multiline": True,
"tooltip": "Same JSON the surgery panel writes."}),
"filename": ("STRING", {"default": "", "multiline": False,
"tooltip": "Leave empty to name it after the rules."}),
"subfolder": ("STRING", {"default": "surgery", "multiline": False}),
"save_dtype": (["keep", "float16", "bfloat16", "float32"],),
"overwrite": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("path",)
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "BFS/lora"
DESCRIPTION = "Apply the same surgery rules and write the result to models/loras."
def save(self, lora_name, rules, filename, subfolder, save_dtype, overwrite):
dest, msg = write_surgery(lora_name, rules, filename, subfolder, save_dtype, overwrite)
print(f"[BFSNodes] LoRA surgery save: {msg}")
return {"ui": {"text": [msg]}, "result": (dest,)}
def write_surgery(lora_name, rules, filename="", subfolder="surgery", save_dtype="keep",
overwrite=False) -> tuple[str, str]:
"""Apply the rules and write the file. Shared by the save node and the panel's button."""
src = folder_paths.get_full_path("loras", lora_name)
if src is None:
raise ValueError(f"LoRA not found: {lora_name}")
parsed: list[dict[str, Any]] = []
if rules and rules.strip():
loaded = json.loads(rules)
if isinstance(loaded, dict):
loaded = loaded.get("rules", [])
if isinstance(loaded, list):
parsed = loaded
sd = comfy.utils.load_torch_file(src, safe_load=True)
edited, stats = apply_rules(sd, parsed)
if save_dtype != "keep":
want = {"float16": torch.float16, "bfloat16": torch.bfloat16, "float32": torch.float32}[save_dtype]
edited = {k: (v.to(want) if v.is_floating_point() else v) for k, v in edited.items()}
stem = os.path.splitext(os.path.basename(src))[0]
typed = re.sub(r"[^A-Za-z0-9._ -]", "", filename).strip()
if typed:
if typed.endswith(".safetensors"):
typed = typed[: -len(".safetensors")]
name = fit_filename(typed, "", "").rstrip("_") + ".safetensors"
else:
name = fit_filename(stem, describe_rules(parsed))
root = folder_paths.get_folder_paths("loras")[0]
out_dir = os.path.join(root, subfolder.strip()) if subfolder.strip() else root
os.makedirs(out_dir, exist_ok=True)
dest = os.path.join(out_dir, name)
if len(dest) > 240:
print(f"[BFSNodes] warning: the path is {len(dest)} characters, which some systems reject")
if os.path.exists(dest) and not overwrite:
base, ext = os.path.splitext(dest)
n = 1
while os.path.exists(f"{base}_{n}{ext}"):
n += 1
dest = f"{base}_{n}{ext}"
meta = {}
try:
with open(src, "rb") as fh:
hn = struct.unpack("<Q", fh.read(8))[0]
meta = json.loads(fh.read(hn)).get("__metadata__", {}) or {}
except Exception: # noqa: BLE001 - metadata is a nicety, not a requirement
meta = {}
meta = {str(k): str(v) for k, v in meta.items()}
total_modules = sum(1 for k in sd if k.endswith(_UP_SUFFIXES))
kept_modules = stats["kept"] if stats["kept"] is not None else total_modules
summary = summarize_rules(parsed)
meta["bfs_surgery"] = json.dumps({
"tool": "ComfyUI-BFSNodes / BFS LoRA Surgery",
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"source_file": os.path.basename(src),
"source_sha256": _file_digest(src),
"recipe": describe_rules(parsed),
"summary": summary,
"rules": parsed,
"modules_total": total_modules,
"modules_kept": kept_modules,
"modules_dropped": stats["dropped"],
"modules_scaled": stats["scaled"],
"save_dtype": save_dtype,
}, ensure_ascii=False)
# a line any viewer shows without knowing about this tool
meta["bfs_surgery_summary"] = (f"{kept_modules}/{total_modules} modules kept: {summary} "
f"(from {os.path.basename(src)})")
meta["ss_output_name"] = os.path.splitext(name)[0]
from safetensors.torch import save_file
save_file({k: v.contiguous() for k, v in edited.items()}, dest, metadata=meta)
total = total_modules
kept = kept_modules
msg = f"saved {os.path.basename(dest)} ({kept}/{total} modules, {stats['dropped']} dropped)"
return dest, msg
NODE_CLASS_MAPPINGS = {"BFSLoraSurgery": BFSLoraSurgery, "BFSLoraSurgerySave": BFSLoraSurgerySave}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSLoraSurgery": "BFS LoRA Surgery",
"BFSLoraSurgerySave": "BFS LoRA Surgery (save)"}
# ---------------------------------------------------------------------------- http api
try:
from aiohttp import web
from server import PromptServer
_STRUCT_CACHE: dict[tuple[str, float], dict] = {}
@PromptServer.instance.routes.get("/bfs/lora/structure")
async def _bfs_lora_structure(request):
name = request.query.get("name", "")
with_norms = request.query.get("norms", "1") not in ("0", "false", "no")
path = folder_paths.get_full_path("loras", name)
if path is None:
return web.json_response({"error": f"LoRA not found: {name}"}, status=404)
key = (path, os.path.getmtime(path))
cached = _STRUCT_CACHE.get(key)
if cached is None or (with_norms and not cached.get("per_module_norms")):
try:
cached = read_structure(path, with_norms=with_norms)
except Exception as exc: # noqa: BLE001 - surface the reason in the UI
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
_STRUCT_CACHE[key] = cached
return web.json_response(cached)
@PromptServer.instance.routes.post("/bfs/lora/save")
async def _bfs_lora_save(request):
body = await request.json()
try:
dest, msg = write_surgery(
body.get("name", ""), json.dumps(body.get("rules", [])),
body.get("filename", ""), body.get("subfolder", "surgery"),
body.get("save_dtype", "keep"), bool(body.get("overwrite", False)))
except Exception as exc: # noqa: BLE001 - the panel shows the reason
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"path": dest, "message": msg})
@PromptServer.instance.routes.post("/bfs/lora/regex")
async def _bfs_lora_regex(request):
body = await request.json()
return web.json_response({
"regex": build_regex(body.get("family"), body.get("type"), body.get("blocks"))})
except Exception as _exc: # noqa: BLE001 - the nodes still work without the panel
print(f"[BFSNodes] LoRA surgery HTTP routes not registered: {_exc!r}")