Vlad Iliescu commited on
Commit ·
a30449a
1
Parent(s): fb27a76
add initial lora loading
Browse files- app.py +66 -6
- lora_utils.py +270 -0
- tests/test_lora_utils.py +53 -0
app.py
CHANGED
|
@@ -19,6 +19,7 @@ import torch
|
|
| 19 |
from huggingface_hub import hf_hub_download
|
| 20 |
|
| 21 |
from diffusers import Ideogram4Pipeline
|
|
|
|
| 22 |
|
| 23 |
# Runtime shim (keeps the bundled diffusers pristine): cu130-era bitsandbytes returns Params4bit.shape as a
|
| 24 |
# plain tuple, but diffusers' check_quantized_param_shape calls .numel() on it. math.prod handles both, so
|
|
@@ -43,6 +44,7 @@ LM_HEAD_REPO = "multimodalart/qwen3-vl-8b-instruct-lm-head"
|
|
| 43 |
AOTI_REPO = "multimodalart/i4-block-aoti"
|
| 44 |
AOTI_BLOCK_FILE = "Ideogram4TransformerBlock/package.pt2"
|
| 45 |
MAX_SEED = 2**31 - 1
|
|
|
|
| 46 |
|
| 47 |
# Prompt upsampling: Ideogram's hosted magic-prompt (default) with the local Qwen graft as fallback.
|
| 48 |
IDEOGRAM_MAGIC_PROMPT_URL = "https://api.ideogram.ai/v1/ideogram-v4/magic-prompt"
|
|
@@ -143,16 +145,48 @@ def _per_step(width, height):
|
|
| 143 |
return max(0.2, _PS_A + _PS_B * ((int(width) // 16) * (int(height) // 16)))
|
| 144 |
|
| 145 |
|
| 146 |
-
def _gpu_duration(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
steps = MODES.get(mode, MODES["Default · 20 steps"])["num_inference_steps"]
|
| 148 |
budget = steps * _per_step(width, height) + DIFFUSION_OVERHEAD_S
|
| 149 |
if do_local:
|
| 150 |
budget += LOCAL_UPSAMPLE_S
|
| 151 |
-
|
|
|
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
@spaces.GPU(duration=_gpu_duration, size="xlarge")
|
| 155 |
-
def _gpu_generate(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
# Overlap the AOTI block-patch with the (transformer-idle) local upsample, if any.
|
| 157 |
aoti_thread = Thread(target=_apply_aoti, daemon=True)
|
| 158 |
aoti_thread.start()
|
|
@@ -180,6 +214,15 @@ def _gpu_generate(final_prompt, mode, width, height, seed, do_local, progress=gr
|
|
| 180 |
caption = json.loads(final_prompt)
|
| 181 |
except Exception:
|
| 182 |
caption = {"prompt": final_prompt}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
return image, int(seed), caption
|
| 184 |
|
| 185 |
|
|
@@ -192,8 +235,12 @@ def generate(
|
|
| 192 |
seed=0,
|
| 193 |
randomize_seed=False,
|
| 194 |
raw_json_prompt="",
|
|
|
|
|
|
|
| 195 |
progress=gr.Progress(track_tqdm=True),
|
| 196 |
):
|
|
|
|
|
|
|
| 197 |
if randomize_seed or seed < 0:
|
| 198 |
seed = random.randint(0, MAX_SEED)
|
| 199 |
|
|
@@ -203,7 +250,7 @@ def generate(
|
|
| 203 |
final_prompt = json.dumps(json.loads(raw_json_prompt), ensure_ascii=False, separators=(",", ":"))
|
| 204 |
except Exception as e:
|
| 205 |
raise gr.Error(f"Raw JSON prompt is not valid JSON: {e}")
|
| 206 |
-
return _gpu_generate(final_prompt, mode, width, height, seed, False)
|
| 207 |
|
| 208 |
# Remote upsample is a network call -> run it here, OFF the GPU. Fall back to local (on-GPU) on failure.
|
| 209 |
final_prompt, do_local = prompt, True
|
|
@@ -218,7 +265,7 @@ def generate(
|
|
| 218 |
print(f"[upsample] remote failed, falling back to local: {e!r}", flush=True)
|
| 219 |
gr.Warning("Ideogram API unavailable — using the local Qwen upsampler.")
|
| 220 |
|
| 221 |
-
return _gpu_generate(final_prompt, mode, width, height, seed, do_local)
|
| 222 |
|
| 223 |
|
| 224 |
@spaces.GPU(size="xlarge")
|
|
@@ -267,6 +314,19 @@ with gr.Blocks(theme=gr.themes.Citrus(), title="Ideogram 4", css=CSS) as demo:
|
|
| 267 |
placeholder='{"prompt":"a ginger cat wearing a tiny wizard hat reading a spellbook"}',
|
| 268 |
buttons=["copy"],
|
| 269 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
with gr.Row():
|
| 271 |
width = gr.Slider(512, 2048, value=1024, step=64, label="Width")
|
| 272 |
height = gr.Slider(512, 2048, value=1024, step=64, label="Height")
|
|
@@ -292,7 +352,7 @@ with gr.Blocks(theme=gr.themes.Citrus(), title="Ideogram 4", css=CSS) as demo:
|
|
| 292 |
|
| 293 |
run.click(
|
| 294 |
generate,
|
| 295 |
-
inputs=[prompt, mode, upsampler, width, height, seed, randomize, raw_json_prompt],
|
| 296 |
outputs=[out_image, seed, out_caption],
|
| 297 |
)
|
| 298 |
|
|
|
|
| 19 |
from huggingface_hub import hf_hub_download
|
| 20 |
|
| 21 |
from diffusers import Ideogram4Pipeline
|
| 22 |
+
from lora_utils import ensure_loras_loaded
|
| 23 |
|
| 24 |
# Runtime shim (keeps the bundled diffusers pristine): cu130-era bitsandbytes returns Params4bit.shape as a
|
| 25 |
# plain tuple, but diffusers' check_quantized_param_shape calls .numel() on it. math.prod handles both, so
|
|
|
|
| 44 |
AOTI_REPO = "multimodalart/i4-block-aoti"
|
| 45 |
AOTI_BLOCK_FILE = "Ideogram4TransformerBlock/package.pt2"
|
| 46 |
MAX_SEED = 2**31 - 1
|
| 47 |
+
LAST_LORAS = {}
|
| 48 |
|
| 49 |
# Prompt upsampling: Ideogram's hosted magic-prompt (default) with the local Qwen graft as fallback.
|
| 50 |
IDEOGRAM_MAGIC_PROMPT_URL = "https://api.ideogram.ai/v1/ideogram-v4/magic-prompt"
|
|
|
|
| 145 |
return max(0.2, _PS_A + _PS_B * ((int(width) // 16) * (int(height) // 16)))
|
| 146 |
|
| 147 |
|
| 148 |
+
def _gpu_duration(
|
| 149 |
+
final_prompt,
|
| 150 |
+
mode,
|
| 151 |
+
width,
|
| 152 |
+
height,
|
| 153 |
+
seed,
|
| 154 |
+
do_local,
|
| 155 |
+
is_raw_json=False,
|
| 156 |
+
lora_spec="",
|
| 157 |
+
lora_scale=1.0,
|
| 158 |
+
progress=None,
|
| 159 |
+
):
|
| 160 |
+
lora_requested = bool(lora_spec and str(lora_spec).strip())
|
| 161 |
+
if is_raw_json:
|
| 162 |
+
return 80 if lora_requested else 20
|
| 163 |
steps = MODES.get(mode, MODES["Default · 20 steps"])["num_inference_steps"]
|
| 164 |
budget = steps * _per_step(width, height) + DIFFUSION_OVERHEAD_S
|
| 165 |
if do_local:
|
| 166 |
budget += LOCAL_UPSAMPLE_S
|
| 167 |
+
if lora_requested:
|
| 168 |
+
budget += 10
|
| 169 |
+
return max(20, int(math.ceil(budget * DURATION_MARGIN)))
|
| 170 |
|
| 171 |
|
| 172 |
@spaces.GPU(duration=_gpu_duration, size="xlarge")
|
| 173 |
+
def _gpu_generate(
|
| 174 |
+
final_prompt,
|
| 175 |
+
mode,
|
| 176 |
+
width,
|
| 177 |
+
height,
|
| 178 |
+
seed,
|
| 179 |
+
do_local,
|
| 180 |
+
is_raw_json=False,
|
| 181 |
+
lora_spec="",
|
| 182 |
+
lora_scale=1.0,
|
| 183 |
+
progress=gr.Progress(track_tqdm=True),
|
| 184 |
+
):
|
| 185 |
+
try:
|
| 186 |
+
active_loras = ensure_loras_loaded(pipe, lora_spec, float(lora_scale), LAST_LORAS)
|
| 187 |
+
except Exception as e:
|
| 188 |
+
raise gr.Error(f"Failed to load LoRA adapters: {e}") from e
|
| 189 |
+
|
| 190 |
# Overlap the AOTI block-patch with the (transformer-idle) local upsample, if any.
|
| 191 |
aoti_thread = Thread(target=_apply_aoti, daemon=True)
|
| 192 |
aoti_thread.start()
|
|
|
|
| 214 |
caption = json.loads(final_prompt)
|
| 215 |
except Exception:
|
| 216 |
caption = {"prompt": final_prompt}
|
| 217 |
+
if active_loras:
|
| 218 |
+
caption["loras"] = [
|
| 219 |
+
{
|
| 220 |
+
"repo": entry["repo_id"],
|
| 221 |
+
"weight_name": entry["weight_name"],
|
| 222 |
+
"scale": entry["scale"],
|
| 223 |
+
}
|
| 224 |
+
for entry in active_loras
|
| 225 |
+
]
|
| 226 |
return image, int(seed), caption
|
| 227 |
|
| 228 |
|
|
|
|
| 235 |
seed=0,
|
| 236 |
randomize_seed=False,
|
| 237 |
raw_json_prompt="",
|
| 238 |
+
lora_spec="",
|
| 239 |
+
lora_scale=1.0,
|
| 240 |
progress=gr.Progress(track_tqdm=True),
|
| 241 |
):
|
| 242 |
+
if isinstance(lora_scale, str):
|
| 243 |
+
lora_scale = float(lora_scale)
|
| 244 |
if randomize_seed or seed < 0:
|
| 245 |
seed = random.randint(0, MAX_SEED)
|
| 246 |
|
|
|
|
| 250 |
final_prompt = json.dumps(json.loads(raw_json_prompt), ensure_ascii=False, separators=(",", ":"))
|
| 251 |
except Exception as e:
|
| 252 |
raise gr.Error(f"Raw JSON prompt is not valid JSON: {e}")
|
| 253 |
+
return _gpu_generate(final_prompt, mode, width, height, seed, False, True, lora_spec, lora_scale)
|
| 254 |
|
| 255 |
# Remote upsample is a network call -> run it here, OFF the GPU. Fall back to local (on-GPU) on failure.
|
| 256 |
final_prompt, do_local = prompt, True
|
|
|
|
| 265 |
print(f"[upsample] remote failed, falling back to local: {e!r}", flush=True)
|
| 266 |
gr.Warning("Ideogram API unavailable — using the local Qwen upsampler.")
|
| 267 |
|
| 268 |
+
return _gpu_generate(final_prompt, mode, width, height, seed, do_local, False, lora_spec, lora_scale)
|
| 269 |
|
| 270 |
|
| 271 |
@spaces.GPU(size="xlarge")
|
|
|
|
| 314 |
placeholder='{"prompt":"a ginger cat wearing a tiny wizard hat reading a spellbook"}',
|
| 315 |
buttons=["copy"],
|
| 316 |
)
|
| 317 |
+
lora_spec = gr.Textbox(
|
| 318 |
+
label="LoRAs (one per line)",
|
| 319 |
+
info="Format: username/repo:weights.safetensors@0.8 or a direct Hugging Face .safetensors URL. Leave empty for no LoRA.",
|
| 320 |
+
lines=4,
|
| 321 |
+
placeholder="vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\nvladi/loras:klein9b/klein_snofs_v1_4.safetensors@0.8",
|
| 322 |
+
)
|
| 323 |
+
lora_scale = gr.Slider(
|
| 324 |
+
0.0,
|
| 325 |
+
2.0,
|
| 326 |
+
value=1.0,
|
| 327 |
+
step=0.05,
|
| 328 |
+
label="LoRA scale multiplier",
|
| 329 |
+
)
|
| 330 |
with gr.Row():
|
| 331 |
width = gr.Slider(512, 2048, value=1024, step=64, label="Width")
|
| 332 |
height = gr.Slider(512, 2048, value=1024, step=64, label="Height")
|
|
|
|
| 352 |
|
| 353 |
run.click(
|
| 354 |
generate,
|
| 355 |
+
inputs=[prompt, mode, upsampler, width, height, seed, randomize, raw_json_prompt, lora_spec, lora_scale],
|
| 356 |
outputs=[out_image, seed, out_caption],
|
| 357 |
)
|
| 358 |
|
lora_utils.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import os
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
ADAPTER_NAME_PREFIX = "custom"
|
| 7 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("hf")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _parse_hf_lora_url(url: str):
|
| 11 |
+
parsed = urlparse(url)
|
| 12 |
+
if "huggingface.co" not in parsed.netloc:
|
| 13 |
+
return None, None
|
| 14 |
+
|
| 15 |
+
path_parts = [part for part in parsed.path.split("/") if part]
|
| 16 |
+
if len(path_parts) < 2:
|
| 17 |
+
return None, None
|
| 18 |
+
|
| 19 |
+
repo_id = f"{path_parts[0]}/{path_parts[1]}"
|
| 20 |
+
weight_parts = path_parts[2:]
|
| 21 |
+
if len(weight_parts) >= 2 and weight_parts[0] in {"blob", "resolve"}:
|
| 22 |
+
weight_parts = weight_parts[2:]
|
| 23 |
+
weight_name = "/".join(weight_parts) if weight_parts else None
|
| 24 |
+
if not weight_name or not weight_name.endswith(".safetensors"):
|
| 25 |
+
return repo_id, None
|
| 26 |
+
return repo_id, weight_name
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _split_lora_spec(spec: str):
|
| 30 |
+
if not spec:
|
| 31 |
+
return None, None
|
| 32 |
+
|
| 33 |
+
spec = spec.strip()
|
| 34 |
+
if not spec:
|
| 35 |
+
return None, None
|
| 36 |
+
|
| 37 |
+
if spec.startswith("http://") or spec.startswith("https://"):
|
| 38 |
+
return _parse_hf_lora_url(spec)
|
| 39 |
+
if ":" in spec:
|
| 40 |
+
repo_id, weight_name = spec.split(":", 1)
|
| 41 |
+
return repo_id.strip(), weight_name.strip()
|
| 42 |
+
return spec, None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _split_adapter_line_scale(line: str):
|
| 46 |
+
if "@" not in line:
|
| 47 |
+
return line, 1.0
|
| 48 |
+
|
| 49 |
+
spec_candidate, scale_candidate = line.rsplit("@", 1)
|
| 50 |
+
try:
|
| 51 |
+
inline_scale = float(scale_candidate.strip())
|
| 52 |
+
except ValueError:
|
| 53 |
+
return line, 1.0
|
| 54 |
+
return spec_candidate.strip(), inline_scale
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def parse_adapter_specs(spec_text: str, global_scale: float):
|
| 58 |
+
if not spec_text or not spec_text.strip():
|
| 59 |
+
return []
|
| 60 |
+
|
| 61 |
+
requested_entries = []
|
| 62 |
+
seen_keys = set()
|
| 63 |
+
|
| 64 |
+
for line_number, raw_line in enumerate(spec_text.splitlines(), start=1):
|
| 65 |
+
line = raw_line.strip()
|
| 66 |
+
if not line:
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
spec, inline_scale = _split_adapter_line_scale(line)
|
| 70 |
+
repo_id, weight_name = _split_lora_spec(spec)
|
| 71 |
+
if not repo_id or not weight_name:
|
| 72 |
+
raise ValueError(
|
| 73 |
+
"Please provide LoRA entries as "
|
| 74 |
+
"'user/repo:weights.safetensors' or direct .safetensors URLs. "
|
| 75 |
+
f"Invalid line {line_number}: {raw_line!r}"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
adapter_key = (repo_id, weight_name)
|
| 79 |
+
if adapter_key in seen_keys:
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"Duplicate LoRA entry for '{repo_id}:{weight_name}' on line {line_number}."
|
| 82 |
+
)
|
| 83 |
+
seen_keys.add(adapter_key)
|
| 84 |
+
|
| 85 |
+
requested_entries.append(
|
| 86 |
+
{
|
| 87 |
+
"key": adapter_key,
|
| 88 |
+
"repo_id": repo_id,
|
| 89 |
+
"weight_name": weight_name,
|
| 90 |
+
"adapter_name": adapter_runtime_name(adapter_key),
|
| 91 |
+
"inline_scale": inline_scale,
|
| 92 |
+
"global_scale": global_scale,
|
| 93 |
+
"scale": inline_scale * global_scale,
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return requested_entries
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def adapter_runtime_name(adapter_key):
|
| 101 |
+
digest = hashlib.sha1(f"{adapter_key[0]}:{adapter_key[1]}".encode("utf-8")).hexdigest()[:12]
|
| 102 |
+
return f"{ADAPTER_NAME_PREFIX}_{digest}"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _iter_adapter_hosts(pipe):
|
| 106 |
+
seen = set()
|
| 107 |
+
for host in (
|
| 108 |
+
pipe,
|
| 109 |
+
getattr(pipe, "transformer", None),
|
| 110 |
+
getattr(pipe, "unconditional_transformer", None),
|
| 111 |
+
):
|
| 112 |
+
if host is None or id(host) in seen:
|
| 113 |
+
continue
|
| 114 |
+
seen.add(id(host))
|
| 115 |
+
yield host
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _flatten_adapter_names(adapter_mapping):
|
| 119 |
+
if isinstance(adapter_mapping, dict):
|
| 120 |
+
names = set()
|
| 121 |
+
for adapters in adapter_mapping.values():
|
| 122 |
+
if isinstance(adapters, str):
|
| 123 |
+
names.add(adapters)
|
| 124 |
+
else:
|
| 125 |
+
names.update(adapters)
|
| 126 |
+
return names
|
| 127 |
+
if isinstance(adapter_mapping, str):
|
| 128 |
+
return {adapter_mapping}
|
| 129 |
+
if adapter_mapping is None:
|
| 130 |
+
return set()
|
| 131 |
+
return set(adapter_mapping)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _sorted_lora_entries(entries):
|
| 135 |
+
return sorted(entries, key=lambda entry: entry["adapter_name"])
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def safe_unload_lora_adapters(pipe):
|
| 139 |
+
deleted = False
|
| 140 |
+
for host in _iter_adapter_hosts(pipe):
|
| 141 |
+
if not hasattr(host, "delete_adapters"):
|
| 142 |
+
continue
|
| 143 |
+
try:
|
| 144 |
+
adapter_names = sorted(_flatten_adapter_names(host.get_list_adapters()))
|
| 145 |
+
except Exception:
|
| 146 |
+
adapter_names = []
|
| 147 |
+
for adapter_name in adapter_names:
|
| 148 |
+
try:
|
| 149 |
+
host.delete_adapters(adapter_name)
|
| 150 |
+
deleted = True
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
if deleted:
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
if hasattr(pipe, "unload_lora_weights"):
|
| 157 |
+
try:
|
| 158 |
+
pipe.unload_lora_weights()
|
| 159 |
+
return
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
+
for host in _iter_adapter_hosts(pipe):
|
| 164 |
+
if hasattr(host, "set_adapters"):
|
| 165 |
+
try:
|
| 166 |
+
host.set_adapters([])
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
if hasattr(host, "disable_adapters"):
|
| 170 |
+
try:
|
| 171 |
+
host.disable_adapters()
|
| 172 |
+
except Exception:
|
| 173 |
+
pass
|
| 174 |
+
if hasattr(host, "disable_lora"):
|
| 175 |
+
try:
|
| 176 |
+
host.disable_lora()
|
| 177 |
+
except Exception:
|
| 178 |
+
pass
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _set_adapters_on_host(host, adapter_names, adapter_weights):
|
| 182 |
+
if not hasattr(host, "set_adapters"):
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
if not adapter_names:
|
| 186 |
+
try:
|
| 187 |
+
host.set_adapters([])
|
| 188 |
+
return True
|
| 189 |
+
except Exception:
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
for kwargs in (
|
| 193 |
+
{"adapter_weights": adapter_weights},
|
| 194 |
+
{"weights": adapter_weights},
|
| 195 |
+
):
|
| 196 |
+
try:
|
| 197 |
+
host.set_adapters(adapter_names, **kwargs)
|
| 198 |
+
return True
|
| 199 |
+
except TypeError:
|
| 200 |
+
continue
|
| 201 |
+
except Exception:
|
| 202 |
+
return False
|
| 203 |
+
return False
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def apply_lora_adapters(pipe, lora_entries):
|
| 207 |
+
if not lora_entries:
|
| 208 |
+
safe_unload_lora_adapters(pipe)
|
| 209 |
+
return
|
| 210 |
+
|
| 211 |
+
sorted_entries = _sorted_lora_entries(lora_entries)
|
| 212 |
+
adapter_names = [entry["adapter_name"] for entry in sorted_entries]
|
| 213 |
+
adapter_weights = [entry["scale"] for entry in sorted_entries]
|
| 214 |
+
|
| 215 |
+
for host in _iter_adapter_hosts(pipe):
|
| 216 |
+
if _set_adapters_on_host(host, adapter_names, adapter_weights):
|
| 217 |
+
return
|
| 218 |
+
|
| 219 |
+
if len(adapter_names) == 1 and hasattr(pipe, "set_lora_scale"):
|
| 220 |
+
pipe.set_lora_scale(adapter_weights[0])
|
| 221 |
+
return
|
| 222 |
+
|
| 223 |
+
raise ValueError("This runtime does not support activating multiple LoRA adapters.")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def load_lora_adapter(pipe, entry, token=HF_TOKEN):
|
| 227 |
+
load_kwargs = {
|
| 228 |
+
"weight_name": entry["weight_name"],
|
| 229 |
+
"adapter_name": entry["adapter_name"],
|
| 230 |
+
}
|
| 231 |
+
if token:
|
| 232 |
+
load_kwargs["token"] = token
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
pipe.load_lora_weights(entry["repo_id"], **load_kwargs)
|
| 236 |
+
except TypeError:
|
| 237 |
+
if not token:
|
| 238 |
+
raise
|
| 239 |
+
load_kwargs.pop("token", None)
|
| 240 |
+
pipe.load_lora_weights(entry["repo_id"], **load_kwargs)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def ensure_loras_loaded(pipe, spec_text: str, global_scale: float, active_by_key: dict, token=HF_TOKEN):
|
| 244 |
+
desired_entries = parse_adapter_specs(spec_text, global_scale)
|
| 245 |
+
desired_by_key = {entry["key"]: entry for entry in desired_entries}
|
| 246 |
+
|
| 247 |
+
if not desired_entries:
|
| 248 |
+
if active_by_key:
|
| 249 |
+
safe_unload_lora_adapters(pipe)
|
| 250 |
+
active_by_key.clear()
|
| 251 |
+
return []
|
| 252 |
+
|
| 253 |
+
if set(active_by_key.keys()) != set(desired_by_key.keys()):
|
| 254 |
+
try:
|
| 255 |
+
safe_unload_lora_adapters(pipe)
|
| 256 |
+
loaded_entries = []
|
| 257 |
+
for entry in _sorted_lora_entries(desired_entries):
|
| 258 |
+
load_lora_adapter(pipe, entry, token=token)
|
| 259 |
+
loaded_entries.append(entry)
|
| 260 |
+
apply_lora_adapters(pipe, loaded_entries)
|
| 261 |
+
except Exception:
|
| 262 |
+
safe_unload_lora_adapters(pipe)
|
| 263 |
+
active_by_key.clear()
|
| 264 |
+
raise
|
| 265 |
+
else:
|
| 266 |
+
apply_lora_adapters(pipe, desired_entries)
|
| 267 |
+
|
| 268 |
+
active_by_key.clear()
|
| 269 |
+
active_by_key.update(desired_by_key)
|
| 270 |
+
return desired_entries
|
tests/test_lora_utils.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from lora_utils import parse_adapter_specs
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ParseAdapterSpecsTest(unittest.TestCase):
|
| 7 |
+
def test_blank_spec_returns_no_entries(self):
|
| 8 |
+
self.assertEqual(parse_adapter_specs("\n \n", 1.0), [])
|
| 9 |
+
|
| 10 |
+
def test_parses_repo_weight_and_default_scale(self):
|
| 11 |
+
entries = parse_adapter_specs("vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors", 1.0)
|
| 12 |
+
|
| 13 |
+
self.assertEqual(entries[0]["repo_id"], "vladi/real-loras")
|
| 14 |
+
self.assertEqual(entries[0]["weight_name"], "ema4_flux2_klein_9b_000002000.safetensors")
|
| 15 |
+
self.assertEqual(entries[0]["scale"], 1.0)
|
| 16 |
+
|
| 17 |
+
def test_parses_multiple_entries_and_inline_scale(self):
|
| 18 |
+
entries = parse_adapter_specs(
|
| 19 |
+
"vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\n"
|
| 20 |
+
"vladi/loras:klein9b/klein_snofs_v1_4.safetensors@0.8",
|
| 21 |
+
0.5,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
self.assertEqual(len(entries), 2)
|
| 25 |
+
self.assertEqual(entries[1]["repo_id"], "vladi/loras")
|
| 26 |
+
self.assertEqual(entries[1]["weight_name"], "klein9b/klein_snofs_v1_4.safetensors")
|
| 27 |
+
self.assertEqual(entries[1]["scale"], 0.4)
|
| 28 |
+
|
| 29 |
+
def test_parses_huggingface_url_with_subfolder(self):
|
| 30 |
+
entries = parse_adapter_specs(
|
| 31 |
+
"https://huggingface.co/vladi/loras/blob/dev/klein9b/klein_snofs_v1_4.safetensors@0.8",
|
| 32 |
+
1.0,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
self.assertEqual(entries[0]["repo_id"], "vladi/loras")
|
| 36 |
+
self.assertEqual(entries[0]["weight_name"], "klein9b/klein_snofs_v1_4.safetensors")
|
| 37 |
+
self.assertEqual(entries[0]["scale"], 0.8)
|
| 38 |
+
|
| 39 |
+
def test_rejects_missing_weight_name(self):
|
| 40 |
+
with self.assertRaises(ValueError):
|
| 41 |
+
parse_adapter_specs("vladi/real-loras", 1.0)
|
| 42 |
+
|
| 43 |
+
def test_rejects_duplicate_entries(self):
|
| 44 |
+
with self.assertRaises(ValueError):
|
| 45 |
+
parse_adapter_specs(
|
| 46 |
+
"vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\n"
|
| 47 |
+
"vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors@0.8",
|
| 48 |
+
1.0,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
unittest.main()
|