lollms-client 1.6.4__py3-none-any.whl → 1.6.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

@@ -18,9 +18,12 @@ import gc
18
18
  import argparse
19
19
  import uvicorn
20
20
  from fastapi import FastAPI, APIRouter, HTTPException, UploadFile, Form
21
+ from fastapi import Request, Response
21
22
  from fastapi.responses import Response
22
23
  from pydantic import BaseModel, Field
23
24
  import sys
25
+ import platform
26
+ import inspect
24
27
 
25
28
  # Add binding root to sys.path to ensure local modules can be imported if structured that way.
26
29
  binding_root = Path(__file__).resolve().parent.parent
@@ -53,13 +56,13 @@ except ImportError as e:
53
56
  # --- Server Setup ---
54
57
  app = FastAPI(title="Diffusers TTI Server")
55
58
  router = APIRouter()
56
- MODELS_PATH = Path("./models") # Default, will be overridden by command-line arg
59
+ MODELS_PATH = Path("./models")
57
60
 
58
61
  # --- START: Core Logic (Complete and Unabridged) ---
59
62
  CIVITAI_MODELS = {
60
63
  "realistic-vision-v6": {
61
64
  "display_name": "Realistic Vision V6.0", "url": "https://civitai.com/api/download/models/501240?type=Model&format=SafeTensor&size=pruned&fp=fp16",
62
- "filename": "realisticVisionV60_v60B1.safetensors", "description": "Photorealistic SD1.5 checkpoint.", "owned_by": "civitai"
65
+ "filename": "realisticVisionV60_v60B1.safensors", "description": "Photorealistic SD1.5 checkpoint.", "owned_by": "civitai"
63
66
  },
64
67
  "absolute-reality": {
65
68
  "display_name": "Absolute Reality", "url": "https://civitai.com/api/download/models/132760?type=Model&format=SafeTensor&size=pruned&fp=fp16",
@@ -119,6 +122,47 @@ CIVITAI_MODELS = {
119
122
  },
120
123
  }
121
124
 
125
+ HF_PUBLIC_MODELS = {
126
+ "General Purpose & SDXL": [
127
+ {"model_name": "stabilityai/stable-diffusion-xl-base-1.0", "display_name": "Stable Diffusion XL 1.0", "desc": "Official 1024x1024 text-to-image model from Stability AI."},
128
+ {"model_name": "stabilityai/sdxl-turbo", "display_name": "SDXL Turbo", "desc": "A fast, real-time text-to-image model based on SDXL."},
129
+ {"model_name": "kandinsky-community/kandinsky-3", "display_name": "Kandinsky 3", "desc": "A powerful multilingual model with strong prompt understanding and aesthetic quality."},
130
+ {"model_name": "playgroundai/playground-v2.5-1024px-aesthetic", "display_name": "Playground v2.5", "desc": "A high-quality model focused on aesthetic outputs."},
131
+ ],
132
+ "Photorealistic": [
133
+ {"model_name": "emilianJR/epiCRealism", "display_name": "epiCRealism", "desc": "A popular community model for generating photorealistic images."},
134
+ {"model_name": "SG161222/Realistic_Vision_V5.1_noVAE", "display_name": "Realistic Vision 5.1", "desc": "One of the most popular realistic models, great for portraits and scenes."},
135
+ {"model_name": "Photon-v1", "display_name": "Photon", "desc": "A model known for high-quality, realistic images with good lighting and detail."},
136
+ ],
137
+ "Anime & Illustration": [
138
+ {"model_name": "hakurei/waifu-diffusion", "display_name": "Waifu Diffusion 1.4", "desc": "A widely-used model for generating high-quality anime-style images."},
139
+ {"model_name": "gsdf/Counterfeit-V3.0", "display_name": "Counterfeit V3.0", "desc": "A strong model for illustrative and 2.5D anime styles."},
140
+ {"model_name": "cagliostrolab/animagine-xl-3.0", "display_name": "Animagine XL 3.0", "desc": "A state-of-the-art anime model on the SDXL architecture."},
141
+ ],
142
+ "Artistic & Stylized": [
143
+ {"model_name": "wavymulder/Analog-Diffusion", "display_name": "Analog Diffusion", "desc": "Creates images with a vintage, analog film aesthetic."},
144
+ {"model_name": "dreamlike-art/dreamlike-photoreal-2.0", "display_name": "Dreamlike Photoreal 2.0", "desc": "Produces stunning, artistic, and photorealistic images."},
145
+ ],
146
+ "Image Editing Tools": [
147
+ {"model_name": "stabilityai/stable-diffusion-xl-refiner-1.0", "display_name": "SDXL Refiner 1.0", "desc": "A dedicated refiner model to improve details in SDXL generations."},
148
+ {"model_name": "Qwen/Qwen-Image-Edit", "display_name": "Qwen Image Edit", "desc": "An instruction-based model for various image editing tasks."},
149
+ {"model_name": "Qwen/Qwen-Image-Edit-2509", "display_name": "Qwen Image Edit Plus", "desc": "Advanced multi-image editing, fusion, and pose transfer."},
150
+ ],
151
+ "Legacy & Base Models": [
152
+ {"model_name": "runwayml/stable-diffusion-v1-5", "display_name": "Stable Diffusion 1.5", "desc": "The classic and versatile SD1.5 base model."},
153
+ {"model_name": "stabilityai/stable-diffusion-2-1", "display_name": "Stable Diffusion 2.1", "desc": "The 768x768 base model from the SD2.x series."},
154
+ ]
155
+ }
156
+
157
+ HF_GATED_MODELS = {
158
+ "Next-Generation (Gated Access Required)": [
159
+ {"model_name": "stabilityai/stable-diffusion-3-medium-diffusers", "display_name": "Stable Diffusion 3 Medium", "desc": "State-of-the-art model with advanced prompt understanding. Requires free registration."},
160
+ {"model_name": "black-forest-labs/FLUX.1-schnell", "display_name": "FLUX.1 Schnell", "desc": "A powerful and extremely fast next-generation model. Requires access request."},
161
+ {"model_name": "black-forest-labs/FLUX.1-dev", "display_name": "FLUX.1 Dev", "desc": "The larger developer version of the FLUX.1 model. Requires access request."},
162
+ ]
163
+ }
164
+
165
+
122
166
  TORCH_DTYPE_MAP_STR_TO_OBJ = {
123
167
  "float16": getattr(torch, 'float16', 'float16'), "bfloat16": getattr(torch, 'bfloat16', 'bfloat16'),
124
168
  "float32": getattr(torch, 'float32', 'float32'), "auto": "auto"
@@ -141,6 +185,7 @@ SCHEDULER_USES_KARRAS_SIGMAS = [
141
185
  "dpm++_2m_sde_karras","dpm2_karras","dpm2_a_karras"
142
186
  ]
143
187
 
188
+
144
189
  class ModelManager:
145
190
  def __init__(self, config: Dict[str, Any], models_path: Path, registry: 'PipelineRegistry'):
146
191
  self.config = config
@@ -159,6 +204,7 @@ class ModelManager:
159
204
  self._stop_monitor_event = threading.Event()
160
205
  self._unload_monitor_thread = None
161
206
  self._start_unload_monitor()
207
+ self.supported_args: Optional[set] = None
162
208
 
163
209
  def acquire(self):
164
210
  with self.lock:
@@ -208,9 +254,25 @@ class ModelManager:
208
254
  if not local_path.exists():
209
255
  self._download_civitai_model(model_name)
210
256
  return local_path
257
+
258
+ # Search in extra models path
259
+ if state.extra_models_path and state.extra_models_path.exists():
260
+ found_paths = list(state.extra_models_path.rglob(model_name))
261
+ if found_paths:
262
+ ASCIIColors.info(f"Found model in extra path: {found_paths[0]}")
263
+ return found_paths[0]
264
+
265
+ # Search in primary models path
266
+ found_paths = list(self.models_path.rglob(model_name))
267
+ if found_paths:
268
+ ASCIIColors.info(f"Found model in primary path: {found_paths[0]}")
269
+ return found_paths[0]
270
+
271
+ # Fallback for HF hub models that are folders, not single files.
211
272
  local_path = self.models_path / model_name
212
273
  if local_path.exists():
213
274
  return local_path
275
+
214
276
  return model_name
215
277
 
216
278
  def _download_civitai_model(self, model_key: str):
@@ -219,7 +281,7 @@ class ModelManager:
219
281
  filename = model_info["filename"]
220
282
  dest_path = self.models_path / filename
221
283
  temp_path = dest_path.with_suffix(".temp")
222
- ASCIIColors.cyan(f"Downloading '{filename}' from Civitai...")
284
+ ASCIIColors.cyan(f"Downloading '{filename}' from Civitai... to {dest_path}")
223
285
  try:
224
286
  with requests.get(url, stream=True) as r:
225
287
  r.raise_for_status()
@@ -233,13 +295,13 @@ class ModelManager:
233
295
  except Exception as e:
234
296
  if temp_path.exists():
235
297
  temp_path.unlink()
236
- raise Exception(f"Failed to download model {filename}: {e}")
298
+ raise Exception(f"Failed to download model {filename}: {e}")
237
299
 
238
300
  def _set_scheduler(self):
239
301
  if not self.pipeline:
240
302
  return
241
- if "Qwen" in self.config.get("model_name", ""):
242
- ASCIIColors.info("Qwen model detected, skipping custom scheduler setup.")
303
+ if "Qwen" in self.config.get("model_name", "") or "FLUX" in self.config.get("model_name", ""):
304
+ ASCIIColors.info("Special model detected, skipping custom scheduler setup.")
243
305
  return
244
306
  scheduler_name_key = self.config["scheduler_name"].lower()
245
307
  if scheduler_name_key == "default":
@@ -256,71 +318,116 @@ class ModelManager:
256
318
  ASCIIColors.warning(f"Could not switch scheduler to {scheduler_name_key}: {e}. Using current default.")
257
319
 
258
320
  def _execute_load_pipeline(self, task: str, model_path: Union[str, Path], torch_dtype: Any):
259
- model_name = self.config.get("model_name", "")
321
+ if platform.system() == "Windows":
322
+ os.environ["HF_HUB_ENABLE_SYMLINKS"] = "0"
323
+
324
+ model_name_from_config = self.config.get("model_name", "")
325
+ use_device_map = False
326
+
260
327
  try:
261
- load_args = {}
328
+ load_params = {}
262
329
  if self.config.get("hf_cache_path"):
263
- load_args["cache_dir"] = str(self.config["hf_cache_path"])
264
- if str(model_path).endswith(".safetensors"):
265
- if task == "text2image":
266
- try:
267
- self.pipeline = AutoPipelineForText2Image.from_single_file(model_path, torch_dtype=torch_dtype, cache_dir=load_args.get("cache_dir"))
268
- except AttributeError:
269
- self.pipeline = StableDiffusionPipeline.from_single_file(model_path, torch_dtype=torch_dtype, cache_dir=load_args.get("cache_dir"))
270
- elif task == "image2image":
271
- self.pipeline = AutoPipelineForImage2Image.from_single_file(model_path, torch_dtype=torch_dtype, cache_dir=load_args.get("cache_dir"))
272
- elif task == "inpainting":
273
- self.pipeline = AutoPipelineForInpainting.from_single_file(model_path, torch_dtype=torch_dtype, cache_dir=load_args.get("cache_dir"))
274
- else:
275
- common_args = {
276
- "torch_dtype": torch_dtype,
330
+ load_params["cache_dir"] = str(self.config["hf_cache_path"])
331
+ load_params["torch_dtype"] = torch_dtype
332
+
333
+ is_qwen_model = "Qwen" in model_name_from_config
334
+ is_flux_model = "FLUX" in model_name_from_config
335
+
336
+ if is_qwen_model or is_flux_model:
337
+ ASCIIColors.info(f"Special model '{model_name_from_config}' detected. Using dedicated pipeline loader.")
338
+ load_params.update({
277
339
  "use_safetensors": self.config["use_safetensors"],
278
340
  "token": self.config["hf_token"],
279
341
  "local_files_only": self.config["local_files_only"]
280
- }
342
+ })
281
343
  if self.config["hf_variant"]:
282
- common_args["variant"] = self.config["hf_variant"]
344
+ load_params["variant"] = self.config["hf_variant"]
283
345
  if not self.config["safety_checker_on"]:
284
- common_args["safety_checker"] = None
285
- if self.config.get("hf_cache_path"):
286
- common_args["cache_dir"] = str(self.config["hf_cache_path"])
287
-
288
- if "Qwen-Image-Edit-2509" in str(model_path):
289
- self.pipeline = QwenImageEditPlusPipeline.from_pretrained(model_path, **common_args)
290
- elif "Qwen-Image-Edit" in str(model_path):
291
- self.pipeline = QwenImageEditPipeline.from_pretrained(model_path, **common_args)
292
- elif "Qwen/Qwen-Image" in str(model_path):
293
- self.pipeline = DiffusionPipeline.from_pretrained(model_path, **common_args)
294
- elif task == "text2image":
295
- self.pipeline = AutoPipelineForText2Image.from_pretrained(model_path, **common_args)
296
- elif task == "image2image":
297
- self.pipeline = AutoPipelineForImage2Image.from_pretrained(model_path, **common_args)
298
- elif task == "inpainting":
299
- self.pipeline = AutoPipelineForInpainting.from_pretrained(model_path, **common_args)
346
+ load_params["safety_checker"] = None
347
+
348
+ should_offload = self.config["enable_cpu_offload"] or self.config["enable_sequential_cpu_offload"]
349
+ if should_offload:
350
+ ASCIIColors.info(f"Offload enabled. Forcing device_map='auto' for {model_name_from_config}.")
351
+ use_device_map = True
352
+ load_params["device_map"] = "auto"
353
+
354
+ if is_flux_model:
355
+ self.pipeline = AutoPipelineForText2Image.from_pretrained(model_name_from_config, **load_params)
356
+ elif "Qwen-Image-Edit-2509" in model_name_from_config:
357
+ self.pipeline = QwenImageEditPlusPipeline.from_pretrained(model_name_from_config, **load_params)
358
+ elif "Qwen-Image-Edit" in model_name_from_config:
359
+ self.pipeline = QwenImageEditPipeline.from_pretrained(model_name_from_config, **load_params)
360
+ elif "Qwen/Qwen-Image" in model_name_from_config:
361
+ self.pipeline = DiffusionPipeline.from_pretrained(model_name_from_config, **load_params)
362
+
363
+ else:
364
+ is_safetensors_file = str(model_path).endswith(".safetensors")
365
+ if is_safetensors_file:
366
+ ASCIIColors.info(f"Loading standard model from local .safetensors file: {model_path}")
367
+ try:
368
+ self.pipeline = AutoPipelineForText2Image.from_single_file(model_path, **load_params)
369
+ except Exception as e:
370
+ ASCIIColors.warning(f"Failed to load with AutoPipeline, falling back to StableDiffusionPipeline: {e}")
371
+ self.pipeline = StableDiffusionPipeline.from_single_file(model_path, **load_params)
372
+ else:
373
+ ASCIIColors.info(f"Loading standard model from Hub: {model_path}")
374
+ load_params.update({
375
+ "use_safetensors": self.config["use_safetensors"],
376
+ "token": self.config["hf_token"],
377
+ "local_files_only": self.config["local_files_only"]
378
+ })
379
+ if self.config["hf_variant"]:
380
+ load_params["variant"] = self.config["hf_variant"]
381
+ if not self.config["safety_checker_on"]:
382
+ load_params["safety_checker"] = None
383
+
384
+ is_large_model = "stable-diffusion-3" in str(model_path)
385
+ should_offload = self.config["enable_cpu_offload"] or self.config["enable_sequential_cpu_offload"]
386
+ if is_large_model and should_offload:
387
+ ASCIIColors.info(f"Large model '{model_path}' detected with offload enabled. Using device_map='auto'.")
388
+ use_device_map = True
389
+ load_params["device_map"] = "auto"
390
+
391
+ if task == "text2image":
392
+ self.pipeline = AutoPipelineForText2Image.from_pretrained(model_path, **load_params)
393
+ elif task == "image2image":
394
+ self.pipeline = AutoPipelineForImage2Image.from_pretrained(model_path, **load_params)
395
+ elif task == "inpainting":
396
+ self.pipeline = AutoPipelineForInpainting.from_pretrained(model_path, **load_params)
397
+
300
398
  except Exception as e:
301
399
  error_str = str(e).lower()
302
400
  if "401" in error_str or "gated" in error_str or "authorization" in error_str:
303
- msg = (
304
- f"AUTHENTICATION FAILED for model '{model_name}'. "
305
- "Please ensure you accepted the model license and provided a valid HF token."
306
- )
401
+ msg = (f"AUTHENTICATION FAILED for model '{model_name_from_config}'. Please ensure you accepted the model license and provided a valid HF token.")
307
402
  raise RuntimeError(msg)
308
403
  raise e
404
+
309
405
  self._set_scheduler()
310
- self.pipeline.to(self.config["device"])
311
- if self.config["enable_xformers"]:
312
- try:
313
- self.pipeline.enable_xformers_memory_efficient_attention()
314
- except Exception as e:
315
- ASCIIColors.warning(f"Could not enable xFormers: {e}.")
316
- if self.config["enable_cpu_offload"] and self.config["device"] != "cpu":
317
- self.pipeline.enable_model_cpu_offload()
318
- elif self.config["enable_sequential_cpu_offload"] and self.config["device"] != "cpu":
319
- self.pipeline.enable_sequential_cpu_offload()
406
+
407
+ if not use_device_map:
408
+ self.pipeline.to(self.config["device"])
409
+ if self.config["enable_xformers"]:
410
+ try:
411
+ self.pipeline.enable_xformers_memory_efficient_attention()
412
+ except Exception as e:
413
+ ASCIIColors.warning(f"Could not enable xFormers: {e}.")
414
+
415
+ if self.config["enable_cpu_offload"] and self.config["device"] != "cpu":
416
+ self.pipeline.enable_model_cpu_offload()
417
+ elif self.config["enable_sequential_cpu_offload"] and self.config["device"] != "cpu":
418
+ self.pipeline.enable_sequential_cpu_offload()
419
+ else:
420
+ ASCIIColors.info("Device map handled device placement. Skipping manual pipeline.to() and offload calls.")
421
+
422
+ if self.pipeline:
423
+ sig = inspect.signature(self.pipeline.__call__)
424
+ self.supported_args = {p.name for p in sig.parameters.values()}
425
+ ASCIIColors.info(f"Pipeline supported arguments detected: {self.supported_args}")
426
+
320
427
  self.is_loaded = True
321
428
  self.current_task = task
322
429
  self.last_used_time = time.time()
323
- ASCIIColors.green(f"Model '{model_name}' loaded successfully on '{self.config['device']}' for task '{task}'.")
430
+ ASCIIColors.green(f"Model '{model_name_from_config}' loaded successfully using '{'device_map' if use_device_map else 'standard'}' mode for task '{task}'.")
324
431
 
325
432
  def _load_pipeline_for_task(self, task: str):
326
433
  if self.pipeline and self.current_task == task:
@@ -346,10 +453,7 @@ class ModelManager:
346
453
 
347
454
  ASCIIColors.warning(f"Failed to load '{model_name}' due to OOM. Attempting to unload other models to free VRAM.")
348
455
 
349
- candidates_to_unload = [
350
- m for m in self.registry.get_all_managers()
351
- if m is not self and m.is_loaded
352
- ]
456
+ candidates_to_unload = [m for m in self.registry.get_all_managers() if m is not self and m.is_loaded]
353
457
  candidates_to_unload.sort(key=lambda m: m.last_used_time)
354
458
 
355
459
  if not candidates_to_unload:
@@ -378,6 +482,7 @@ class ModelManager:
378
482
  model_name = self.config.get('model_name', 'Unknown')
379
483
  del self.pipeline
380
484
  self.pipeline = None
485
+ self.supported_args = None
381
486
  gc.collect()
382
487
  if torch and torch.cuda.is_available():
383
488
  torch.cuda.empty_cache()
@@ -398,8 +503,16 @@ class ModelManager:
398
503
  self.last_used_time = time.time()
399
504
  if not self.is_loaded or self.current_task != task:
400
505
  self._load_pipeline_for_task(task)
506
+
507
+ if self.supported_args:
508
+ filtered_args = {k: v for k, v in pipeline_args.items() if k in self.supported_args}
509
+ else:
510
+ ASCIIColors.warning("Supported argument set not found. Using unfiltered arguments.")
511
+ filtered_args = pipeline_args
512
+
401
513
  with torch.no_grad():
402
- output = self.pipeline(**pipeline_args)
514
+ output = self.pipeline(**filtered_args)
515
+
403
516
  pil = output.images[0]
404
517
  buf = BytesIO()
405
518
  pil.save(buf, format="PNG")
@@ -409,7 +522,6 @@ class ModelManager:
409
522
  future.set_exception(e)
410
523
  finally:
411
524
  self.queue.task_done()
412
- # Aggressive cleanup
413
525
  if output is not None:
414
526
  del output
415
527
  gc.collect()
@@ -465,24 +577,25 @@ class PipelineRegistry:
465
577
  return list(self._managers.values())
466
578
 
467
579
  class ServerState:
468
- def __init__(self, models_path: Path):
580
+ def __init__(self, models_path: Path, extra_models_path: Optional[Path] = None):
469
581
  self.models_path = models_path
582
+ self.extra_models_path = extra_models_path
470
583
  self.models_path.mkdir(parents=True, exist_ok=True)
584
+ if self.extra_models_path:
585
+ self.extra_models_path.mkdir(parents=True, exist_ok=True)
471
586
  self.config_path = self.models_path.parent / "diffusers_server_config.json"
472
587
  self.registry = PipelineRegistry()
473
588
  self.manager: Optional[ModelManager] = None
474
589
  self.config = {}
475
- self.load_config() # This will set self.config
590
+ self.load_config()
476
591
  self._resolve_device_and_dtype()
477
-
478
- # Eagerly acquire manager at startup if a model is configured
479
592
  if self.config.get("model_name"):
480
593
  try:
481
594
  ASCIIColors.info(f"Acquiring initial model manager for '{self.config['model_name']}' on startup.")
482
595
  self.manager = self.registry.get_manager(self.config, self.models_path)
483
596
  except Exception as e:
484
597
  ASCIIColors.error(f"Failed to acquire model manager on startup: {e}")
485
- self.manager = None # Ensure manager is None on failure
598
+ self.manager = None
486
599
 
487
600
  def get_default_config(self) -> Dict[str, Any]:
488
601
  return {
@@ -495,7 +608,6 @@ class ServerState:
495
608
  }
496
609
 
497
610
  def save_config(self):
498
- """Saves the current configuration to a JSON file."""
499
611
  try:
500
612
  with open(self.config_path, 'w') as f:
501
613
  json.dump(self.config, f, indent=4)
@@ -504,13 +616,11 @@ class ServerState:
504
616
  ASCIIColors.error(f"Failed to save server config: {e}")
505
617
 
506
618
  def load_config(self):
507
- """Loads configuration from JSON file, falling back to defaults."""
508
619
  default_config = self.get_default_config()
509
620
  if self.config_path.exists():
510
621
  try:
511
622
  with open(self.config_path, 'r') as f:
512
623
  loaded_config = json.load(f)
513
- # Merge loaded config into defaults to ensure all keys are present
514
624
  default_config.update(loaded_config)
515
625
  self.config = default_config
516
626
  ASCIIColors.info(f"Loaded server configuration from {self.config_path}")
@@ -519,53 +629,45 @@ class ServerState:
519
629
  self.config = default_config
520
630
  else:
521
631
  self.config = default_config
522
- # Save back to ensure file exists and is up-to-date with all keys
523
632
  self.save_config()
524
633
 
525
634
  def _resolve_device_and_dtype(self):
526
635
  if self.config.get("device", "auto").lower() == "auto":
527
636
  self.config["device"] = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
528
637
 
529
- # Prioritize bfloat16 for Qwen models on supported hardware, as it's more stable
530
- if "Qwen" in self.config.get("model_name", "") and self.config["device"] == "cuda":
638
+ if ("Qwen" in self.config.get("model_name", "") or "FLUX" in self.config.get("model_name", "")) and self.config["device"] == "cuda":
531
639
  if hasattr(torch.cuda, 'is_bf16_supported') and torch.cuda.is_bf16_supported():
532
640
  self.config["torch_dtype_str"] = "bfloat16"
533
- ASCIIColors.info("Qwen model detected on compatible hardware. Forcing dtype to bfloat16 for stability.")
641
+ ASCIIColors.info("Special model detected on compatible hardware. Forcing dtype to bfloat16 for stability.")
534
642
  return
535
643
 
536
644
  if self.config["torch_dtype_str"].lower() == "auto":
537
645
  self.config["torch_dtype_str"] = "float16" if self.config["device"] != "cpu" else "float32"
538
646
 
539
647
  def update_settings(self, new_settings: Dict[str, Any]):
540
- """Updates settings, swaps the manager if critical settings change, and saves the config."""
541
648
  if 'model' in new_settings and 'model_name' not in new_settings:
542
649
  new_settings['model_name'] = new_settings.pop('model')
543
650
 
544
- # Safeguard: If a model is already configured and the new settings don't specify one,
545
- # keep the old one. This prevents a misconfigured client from wiping a valid server state.
546
651
  if self.config.get("model_name") and not new_settings.get("model_name"):
547
652
  ASCIIColors.info("Incoming settings have no model_name. Preserving existing model.")
548
653
  new_settings["model_name"] = self.config["model_name"]
549
654
 
550
- # Release old manager if it exists
551
655
  if self.manager:
552
656
  self.registry.release_manager(self.manager.config)
553
657
  self.manager = None
554
658
 
555
- # Update the config in memory
556
659
  self.config.update(new_settings)
557
660
  ASCIIColors.info(f"Server config updated. Current model_name: {self.config.get('model_name')}")
558
661
 
559
662
  self._resolve_device_and_dtype()
560
663
 
561
- # Acquire new manager with the updated config
562
664
  if self.config.get("model_name"):
563
665
  ASCIIColors.info("Acquiring model manager with updated configuration...")
564
666
  self.manager = self.registry.get_manager(self.config, self.models_path)
565
667
  else:
566
668
  ASCIIColors.warning("No model_name in config after update, manager not acquired.")
567
669
 
568
- self.save_config() # Persist the new state
670
+ self.save_config()
569
671
  return True
570
672
 
571
673
  def get_active_manager(self) -> ModelManager:
@@ -586,77 +688,208 @@ class EditRequestPayload(BaseModel):
586
688
  image_paths: List[str] = Field(default_factory=list)
587
689
  params: Dict[str, Any] = Field(default_factory=dict)
588
690
 
691
+ class EditRequestJSON(BaseModel):
692
+ prompt: str
693
+ images_b64: List[str] = Field(description="A list of Base64 encoded image strings.")
694
+ params: Dict[str, Any] = Field(default_factory=dict)
695
+ def get_sanitized_request_for_logging(request_data: Any) -> Dict[str, Any]:
696
+ """
697
+ Takes a request object (Pydantic model or dict) and returns a 'safe' dictionary
698
+ for logging, with long base64 strings replaced by placeholders.
699
+ """
700
+ import copy
701
+
702
+ try:
703
+ if hasattr(request_data, 'model_dump'):
704
+ data = request_data.model_dump()
705
+ elif isinstance(request_data, dict):
706
+ data = copy.deepcopy(request_data)
707
+ else:
708
+ return {"error": "Unsupported data type for sanitization"}
709
+
710
+ # Sanitize the main list of images
711
+ if 'images_b64' in data and isinstance(data['images_b64'], list):
712
+ count = len(data['images_b64'])
713
+ data['images_b64'] = f"[<{count} base64 image(s) truncated>]"
714
+
715
+ # Sanitize a potential mask in the 'params' dictionary
716
+ if 'params' in data and isinstance(data.get('params'), dict):
717
+ if 'mask_image' in data['params'] and isinstance(data['params']['mask_image'], str):
718
+ original_len = len(data['params']['mask_image'])
719
+ data['params']['mask_image'] = f"[<base64 mask truncated, len={original_len}>]"
720
+
721
+ return data
722
+ except Exception:
723
+ return {"error": "Failed to sanitize request data."}
724
+
589
725
  # --- API Endpoints ---
590
726
  @router.post("/generate_image")
591
727
  async def generate_image(request: T2IRequest):
728
+ manager = None
729
+ temp_config = None
592
730
  try:
593
- manager = state.get_active_manager()
594
731
  params = request.params
595
- seed = int(params.get("seed", state.config.get("seed", -1)))
732
+
733
+ # Determine which model manager to use for this specific request
734
+ if "model_name" in params and params["model_name"]:
735
+ temp_config = state.config.copy()
736
+ temp_config["model_name"] = params.pop("model_name") # Remove from params to avoid being passed to pipeline
737
+ manager = state.registry.get_manager(temp_config, state.models_path)
738
+ ASCIIColors.info(f"Using per-request model: {temp_config['model_name']}")
739
+ else:
740
+ manager = state.get_active_manager()
741
+ ASCIIColors.info(f"Using session-configured model: {manager.config.get('model_name')}")
742
+
743
+ seed = int(params.get("seed", manager.config.get("seed", -1)))
596
744
  generator = None
597
745
  if seed != -1:
598
- generator = torch.Generator(device=state.config["device"]).manual_seed(seed)
599
-
746
+ generator = torch.Generator(device=manager.config["device"]).manual_seed(seed)
747
+
748
+ width = int(params.get("width", manager.config.get("width", 512)))
749
+ height = int(params.get("height", manager.config.get("height", 512)))
750
+
600
751
  pipeline_args = {
601
- "prompt": request.prompt, "negative_prompt": request.negative_prompt,
602
- "width": int(params.get("width", state.config.get("width", 512))),
603
- "height": int(params.get("height", state.config.get("height", 512))),
604
- "num_inference_steps": int(params.get("num_inference_steps", state.config.get("num_inference_steps", 25))),
605
- "guidance_scale": float(params.get("guidance_scale", state.config.get("guidance_scale", 7.0))),
752
+ "prompt": request.prompt,
753
+ "negative_prompt": request.negative_prompt,
754
+ "width": width,
755
+ "height": height,
756
+ "num_inference_steps": int(params.get("num_inference_steps", manager.config.get("num_inference_steps", 25))),
757
+ "guidance_scale": float(params.get("guidance_scale", manager.config.get("guidance_scale", 7.0))),
606
758
  "generator": generator
607
759
  }
760
+ pipeline_args.update(params)
761
+
762
+ model_name = manager.config.get("model_name", "")
763
+ task = "text2image"
764
+
765
+ if "Qwen-Image-Edit" in model_name:
766
+ rng_seed = seed if seed != -1 else None
767
+ rng = np.random.default_rng(seed=rng_seed)
768
+ random_pixels = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
769
+ placeholder_image = Image.fromarray(random_pixels, 'RGB')
770
+ pipeline_args["image"] = placeholder_image
771
+ pipeline_args["strength"] = float(params.get("strength", 1.0))
772
+ task = "image2image"
608
773
 
609
774
  future = Future()
610
- manager.queue.put((future,"text2image", pipeline_args))
775
+ manager.queue.put((future, task, pipeline_args))
611
776
  result_bytes = future.result()
612
777
  return Response(content=result_bytes, media_type="image/png")
613
778
  except Exception as e:
614
779
  trace_exception(e)
615
780
  raise HTTPException(status_code=500, detail=str(e))
781
+ finally:
782
+ if temp_config and manager:
783
+ state.registry.release_manager(temp_config)
784
+ ASCIIColors.info(f"Released per-request model: {temp_config['model_name']}")
785
+
616
786
 
617
787
  @router.post("/edit_image")
618
- async def edit_image(json_payload: str = Form(...), files: List[UploadFile] = []):
788
+ async def edit_image(request: EditRequestJSON):
789
+ manager = None
790
+ temp_config = None
619
791
  try:
620
- data = EditRequestPayload.parse_raw(json_payload)
621
- manager = state.get_active_manager()
792
+ params = request.params
793
+
794
+ if "model_name" in params and params["model_name"]:
795
+ temp_config = state.config.copy()
796
+ temp_config["model_name"] = params.pop("model_name")
797
+ manager = state.registry.get_manager(temp_config, state.models_path)
798
+ ASCIIColors.info(f"Using per-request model: {temp_config['model_name']}")
799
+ else:
800
+ manager = state.get_active_manager()
801
+ ASCIIColors.info(f"Using session-configured model: {manager.config.get('model_name')}")
802
+
803
+ model_name = manager.config.get("model_name", "")
622
804
 
623
805
  pil_images = []
624
- for file in files:
625
- contents = await file.read()
626
- pil_images.append(Image.open(BytesIO(contents)).convert("RGB"))
627
-
628
- for path in data.image_paths:
629
- pil_images.append(load_image(path).convert("RGB"))
806
+ for b64_string in request.images_b64:
807
+ b64_data = b64_string.split(";base64,")[1] if ";base64," in b64_string else b64_string
808
+ image_bytes = base64.b64decode(b64_data)
809
+ pil_images.append(Image.open(BytesIO(image_bytes)).convert("RGB"))
630
810
 
631
- if not pil_images:
632
- raise HTTPException(status_code=400, detail="No images provided for editing.")
811
+ if not pil_images: raise HTTPException(status_code=400, detail="No valid images provided.")
812
+
813
+ pipeline_args = {"prompt": request.prompt}
814
+ seed = int(params.get("seed", -1))
815
+ if seed != -1: pipeline_args["generator"] = torch.Generator(device=manager.config["device"]).manual_seed(seed)
816
+
817
+ if "mask_image" in params and params["mask_image"]:
818
+ b64_mask = params["mask_image"]
819
+ b64_data = b64_mask.split(";base64,")[1] if ";base64," in b64_mask else b64_mask
820
+ mask_bytes = base64.b64decode(b64_data)
821
+ pipeline_args["mask_image"] = Image.open(BytesIO(mask_bytes)).convert("L")
633
822
 
634
- task = "inpainting" if data.params.get("mask") else "image2image"
823
+ task = "inpainting" if "mask_image" in pipeline_args else "image2image"
824
+
825
+ if "Qwen-Image-Edit-2509" in model_name:
826
+ task = "image2image"
827
+ pipeline_args.update({"true_cfg_scale": 4.0, "guidance_scale": 1.0, "num_inference_steps": 40, "negative_prompt": " "})
828
+ edit_mode = params.get("edit_mode", "fusion")
829
+ if edit_mode == "fusion": pipeline_args["image"] = pil_images
830
+ else:
831
+ pipeline_args.update({"image": pil_images[0], "strength": 0.8, "guidance_scale": 7.5, "num_inference_steps": 25})
635
832
 
636
- pipeline_args = {
637
- "prompt": data.prompt,
638
- "image": pil_images[0], # Simple i2i for now
639
- "strength": float(data.params.get("strength", 0.8)),
640
- # Add other params like mask etc.
641
- }
833
+ pipeline_args.update(params)
642
834
 
643
- future = Future()
644
- manager.queue.put((future, task, pipeline_args))
645
- result_bytes = future.result()
646
- return Response(content=result_bytes, media_type="image/png")
835
+ future = Future(); manager.queue.put((future, task, pipeline_args))
836
+ return Response(content=future.result(), media_type="image/png")
647
837
  except Exception as e:
838
+ sanitized_payload = get_sanitized_request_for_logging(request)
839
+ ASCIIColors.error(f"Exception in /edit_image. Sanitized Payload: {json.dumps(sanitized_payload, indent=2)}")
648
840
  trace_exception(e)
649
841
  raise HTTPException(status_code=500, detail=str(e))
842
+ finally:
843
+ if temp_config and manager:
844
+ state.registry.release_manager(temp_config)
845
+ ASCIIColors.info(f"Released per-request model: {temp_config['model_name']}")
846
+
650
847
 
651
848
  @router.get("/list_models")
652
849
  def list_models_endpoint():
653
- civitai = [{'model_name': key, 'display_name': info['display_name'], 'description': info['description'], 'owned_by': info['owned_by']} for key, info in CIVITAI_MODELS.items()]
654
- local = [{'model_name': f.name, 'display_name': f.stem, 'description': 'Local safetensors file.', 'owned_by': 'local_user'} for f in state.models_path.glob("*.safetensors")]
655
- return civitai + local
850
+ huggingface_models = []
851
+ # Add public models, organized by category
852
+ for category, models in HF_PUBLIC_MODELS.items():
853
+ for model_info in models:
854
+ huggingface_models.append({
855
+ 'model_name': model_info['model_name'],
856
+ 'display_name': model_info['display_name'],
857
+ 'description': f"({category}) {model_info['desc']}",
858
+ 'owned_by': 'huggingface'
859
+ })
860
+
861
+ # Conditionally add gated models if an HF token is provided in the server config
862
+ if state.config.get("hf_token"):
863
+ ASCIIColors.info("HF token detected, including gated models in the list.")
864
+ for category, models in HF_GATED_MODELS.items():
865
+ for model_info in models:
866
+ huggingface_models.append({
867
+ 'model_name': model_info['model_name'],
868
+ 'display_name': model_info['display_name'],
869
+ 'description': f"({category}) {model_info['desc']}",
870
+ 'owned_by': 'huggingface'
871
+ })
872
+ else:
873
+ ASCIIColors.info("No HF token found, showing public models only.")
874
+
875
+ civitai_models = [{'model_name': key, 'display_name': info['display_name'], 'description': f"(Civitai) {info['description']}", 'owned_by': info['owned_by']} for key, info in CIVITAI_MODELS.items()]
876
+
877
+ local_files = list_local_models_endpoint()
878
+ local_models = [{'model_name': filename, 'display_name': Path(filename).stem, 'description': '(Local) Local safetensors file.', 'owned_by': 'local_user'} for filename in local_files]
879
+
880
+ return huggingface_models + civitai_models + local_models
656
881
 
657
882
  @router.get("/list_local_models")
658
883
  def list_local_models_endpoint():
659
- return sorted([f.name for f in state.models_path.glob("*.safetensors")])
884
+ local_models = set()
885
+ # Main models path
886
+ for f in state.models_path.glob("**/*.safetensors"):
887
+ local_models.add(f.name)
888
+ # Extra models path
889
+ if state.extra_models_path and state.extra_models_path.exists():
890
+ for f in state.extra_models_path.glob("**/*.safetensors"):
891
+ local_models.add(f.name)
892
+ return sorted(list(local_models))
660
893
 
661
894
  @router.get("/list_available_models")
662
895
  def list_available_models_endpoint():
@@ -666,7 +899,6 @@ def list_available_models_endpoint():
666
899
  @router.get("/get_settings")
667
900
  def get_settings_endpoint():
668
901
  settings_list = []
669
- # Add options for dropdowns
670
902
  available_models = list_available_models_endpoint()
671
903
  schedulers = list(SCHEDULER_MAPPING.keys())
672
904
  config_to_display = state.config or state.get_default_config()
@@ -714,14 +946,18 @@ if __name__ == "__main__":
714
946
  parser.add_argument("--host", type=str, default="localhost", help="Host to bind to.")
715
947
  parser.add_argument("--port", type=int, default=9630, help="Port to bind to.")
716
948
  parser.add_argument("--models-path", type=str, required=True, help="Path to the models directory.")
949
+ parser.add_argument("--extra-models-path", type=str, default=None, help="Path to an extra models directory.")
717
950
  args = parser.parse_args()
718
951
 
719
952
  MODELS_PATH = Path(args.models_path)
720
- state = ServerState(MODELS_PATH)
953
+ EXTRA_MODELS_PATH = Path(args.extra_models_path) if args.extra_models_path else None
954
+ state = ServerState(MODELS_PATH, EXTRA_MODELS_PATH)
721
955
 
722
956
  ASCIIColors.cyan(f"--- Diffusers TTI Server ---")
723
957
  ASCIIColors.green(f"Starting server on http://{args.host}:{args.port}")
724
958
  ASCIIColors.green(f"Serving models from: {MODELS_PATH.resolve()}")
959
+ if EXTRA_MODELS_PATH:
960
+ ASCIIColors.green(f"Serving extra models from: {EXTRA_MODELS_PATH.resolve()}")
725
961
  if not DIFFUSERS_AVAILABLE:
726
962
  ASCIIColors.error("Diffusers or its dependencies are not installed correctly in the server's environment!")
727
963
  else: