lemonade-sdk 8.1.11__py3-none-any.whl → 8.2.2__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 lemonade-sdk might be problematic. Click here for more details.

Files changed (38) hide show
  1. lemonade/cache.py +6 -1
  2. lemonade/common/status.py +4 -4
  3. lemonade/common/system_info.py +0 -26
  4. lemonade/tools/accuracy.py +143 -48
  5. lemonade/tools/adapter.py +6 -1
  6. lemonade/tools/bench.py +26 -8
  7. lemonade/tools/flm/utils.py +70 -22
  8. lemonade/tools/huggingface/bench.py +6 -1
  9. lemonade/tools/llamacpp/bench.py +146 -27
  10. lemonade/tools/llamacpp/load.py +30 -2
  11. lemonade/tools/llamacpp/utils.py +317 -21
  12. lemonade/tools/oga/bench.py +5 -26
  13. lemonade/tools/oga/load.py +49 -123
  14. lemonade/tools/oga/migration.py +403 -0
  15. lemonade/tools/report/table.py +76 -8
  16. lemonade/tools/server/flm.py +2 -6
  17. lemonade/tools/server/llamacpp.py +43 -2
  18. lemonade/tools/server/serve.py +354 -18
  19. lemonade/tools/server/static/js/chat.js +15 -77
  20. lemonade/tools/server/static/js/model-settings.js +24 -3
  21. lemonade/tools/server/static/js/models.js +440 -37
  22. lemonade/tools/server/static/js/shared.js +61 -8
  23. lemonade/tools/server/static/logs.html +157 -13
  24. lemonade/tools/server/static/styles.css +204 -0
  25. lemonade/tools/server/static/webapp.html +39 -1
  26. lemonade/version.py +1 -1
  27. lemonade_install/install.py +33 -579
  28. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/METADATA +6 -4
  29. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/RECORD +38 -37
  30. lemonade_server/cli.py +10 -0
  31. lemonade_server/model_manager.py +172 -11
  32. lemonade_server/pydantic_models.py +3 -0
  33. lemonade_server/server_models.json +102 -66
  34. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/WHEEL +0 -0
  35. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/entry_points.txt +0 -0
  36. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/LICENSE +0 -0
  37. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/NOTICE.md +0 -0
  38. {lemonade_sdk-8.1.11.dist-info → lemonade_sdk-8.2.2.dist-info}/top_level.txt +0 -0
@@ -38,6 +38,17 @@ execution_providers = {
38
38
  }
39
39
 
40
40
 
41
+ def find_onnx_files_recursively(directory):
42
+ """
43
+ Recursively search for ONNX files in a directory and its subdirectories.
44
+ """
45
+ for _, _, files in os.walk(directory):
46
+ for file in files:
47
+ if file.endswith(".onnx"):
48
+ return True
49
+ return False
50
+
51
+
41
52
  def _get_npu_driver_version():
42
53
  """
43
54
  Get the NPU driver version using PowerShell directly.
@@ -321,6 +332,7 @@ class OgaLoad(FirstTool):
321
332
 
322
333
  @staticmethod
323
334
  def _setup_model_dependencies(full_model_path, device, ryzenai_version, oga_path):
335
+ # pylint: disable=unused-argument
324
336
  """
325
337
  Sets up model dependencies for hybrid and NPU inference by:
326
338
  1. Configuring the custom_ops_library path in genai_config.json.
@@ -328,76 +340,45 @@ class OgaLoad(FirstTool):
328
340
  3. Check NPU driver version if required for device and ryzenai_version.
329
341
  """
330
342
 
331
- env_path = sys.prefix
343
+ # For RyzenAI 1.6.0, check NPU driver version for NPU and hybrid devices
344
+ if device in ["npu", "hybrid"]:
345
+ required_driver_version = REQUIRED_NPU_DRIVER_VERSION
332
346
 
333
- if "1.4.0" in ryzenai_version:
334
- if device == "npu":
335
- custom_ops_path = os.path.join(
336
- oga_path, "libs", "onnxruntime_vitis_ai_custom_ops.dll"
337
- )
338
- else:
339
- custom_ops_path = os.path.join(oga_path, "libs", "onnx_custom_ops.dll")
340
- else:
341
- # For 1.5.0+, check NPU driver version for NPU and hybrid devices
342
- if device in ["npu", "hybrid"]:
343
- required_driver_version = REQUIRED_NPU_DRIVER_VERSION
344
-
345
- current_driver_version = _get_npu_driver_version()
346
-
347
- if not current_driver_version:
348
- printing.log_warning(
349
- f"NPU driver not found. {device.upper()} inference requires NPU driver "
350
- f"version {required_driver_version}.\n"
351
- "Please download and install the NPU Driver from:\n"
352
- f"{NPU_DRIVER_DOWNLOAD_URL}\n"
353
- "NPU functionality may not work properly."
354
- )
355
- _open_driver_install_page()
356
-
357
- elif not _compare_driver_versions(
358
- current_driver_version, required_driver_version
359
- ):
360
- printing.log_warning(
361
- f"Incorrect NPU driver version detected: {current_driver_version}\n"
362
- f"{device.upper()} inference with RyzenAI 1.5.0 requires driver "
363
- f"version {required_driver_version} or higher.\n"
364
- "Please download and install the correct NPU Driver from:\n"
365
- f"{NPU_DRIVER_DOWNLOAD_URL}\n"
366
- "NPU functionality may not work properly."
367
- )
368
- _open_driver_install_page()
369
-
370
- if device == "npu":
371
- # For 1.5.0, custom ops are in the conda environment's onnxruntime package
372
- custom_ops_path = os.path.join(
373
- env_path,
374
- "Lib",
375
- "site-packages",
376
- "onnxruntime",
377
- "capi",
378
- "onnxruntime_vitis_ai_custom_ops.dll",
379
- )
380
- dll_source_path = os.path.join(
381
- env_path, "Lib", "site-packages", "onnxruntime", "capi"
382
- )
383
- required_dlls = ["dyn_dispatch_core.dll", "xaiengine.dll"]
384
- else:
385
- custom_ops_path = os.path.join(
386
- env_path,
387
- "Lib",
388
- "site-packages",
389
- "onnxruntime_genai",
390
- "onnx_custom_ops.dll",
347
+ current_driver_version = _get_npu_driver_version()
348
+ rai_version, _ = _get_ryzenai_version_info(device)
349
+
350
+ if not current_driver_version:
351
+ printing.log_warning(
352
+ f"NPU driver not found. {device.upper()} inference requires NPU driver "
353
+ f"version {required_driver_version}.\n"
354
+ "Please download and install the NPU Driver from:\n"
355
+ f"{NPU_DRIVER_DOWNLOAD_URL}\n"
356
+ "NPU functionality may not work properly."
391
357
  )
392
- dll_source_path = os.path.join(
393
- env_path, "Lib", "site-packages", "onnxruntime_genai"
358
+ _open_driver_install_page()
359
+
360
+ elif not _compare_driver_versions(
361
+ current_driver_version, required_driver_version
362
+ ):
363
+ printing.log_warning(
364
+ f"Incorrect NPU driver version detected: {current_driver_version}\n"
365
+ f"{device.upper()} inference with RyzenAI {rai_version} requires driver "
366
+ f"version {required_driver_version} or higher.\n"
367
+ "Please download and install the correct NPU Driver from:\n"
368
+ f"{NPU_DRIVER_DOWNLOAD_URL}\n"
369
+ "NPU functionality may not work properly."
394
370
  )
395
- required_dlls = ["libutf8_validity.dll", "abseil_dll.dll"]
371
+ _open_driver_install_page()
372
+
373
+ # Setup DLL paths for NPU/hybrid inference
374
+ env_path = os.path.dirname(sys.executable)
375
+ dll_source_path = os.path.join(
376
+ env_path, "Lib", "site-packages", "onnxruntime_genai"
377
+ )
378
+ required_dlls = ["libutf8_validity.dll", "abseil_dll.dll"]
396
379
 
397
380
  # Validate that all required DLLs exist in the source directory
398
381
  missing_dlls = []
399
- if not os.path.exists(custom_ops_path):
400
- missing_dlls.append(custom_ops_path)
401
382
 
402
383
  for dll_name in required_dlls:
403
384
  dll_source = os.path.join(dll_source_path, dll_name)
@@ -408,7 +389,9 @@ class OgaLoad(FirstTool):
408
389
  dll_list = "\n - ".join(missing_dlls)
409
390
  raise RuntimeError(
410
391
  f"Required DLLs not found for {device} inference:\n - {dll_list}\n"
411
- f"Please ensure your RyzenAI installation is complete and supports {device}."
392
+ f"Please ensure your RyzenAI installation is complete and supports {device}.\n"
393
+ "Please reinstall the RyzenAI Software for your platform. Run:\n"
394
+ " pip install lemonade-sdk[oga-ryzenai]\n"
412
395
  )
413
396
 
414
397
  # Add the DLL source directory to PATH
@@ -416,29 +399,6 @@ class OgaLoad(FirstTool):
416
399
  if dll_source_path not in current_path:
417
400
  os.environ["PATH"] = dll_source_path + os.pathsep + current_path
418
401
 
419
- # Update the model config with custom_ops_library path
420
- config_path = os.path.join(full_model_path, "genai_config.json")
421
- if os.path.exists(config_path):
422
- with open(config_path, "r", encoding="utf-8") as f:
423
- config = json.load(f)
424
-
425
- if (
426
- "model" in config
427
- and "decoder" in config["model"]
428
- and "session_options" in config["model"]["decoder"]
429
- ):
430
- config["model"]["decoder"]["session_options"][
431
- "custom_ops_library"
432
- ] = custom_ops_path
433
-
434
- with open(config_path, "w", encoding="utf-8") as f:
435
- json.dump(config, f, indent=4)
436
-
437
- else:
438
- printing.log_info(
439
- f"Model's `genai_config.json` not found in {full_model_path}"
440
- )
441
-
442
402
  @staticmethod
443
403
  def _is_preoptimized_model(input_model_path):
444
404
  """
@@ -502,34 +462,6 @@ class OgaLoad(FirstTool):
502
462
 
503
463
  return full_model_path
504
464
 
505
- @staticmethod
506
- def _setup_npu_environment(ryzenai_version, oga_path):
507
- """
508
- Sets up environment for NPU flow of ONNX model and returns saved state to be restored
509
- later in cleanup.
510
- """
511
- if "1.5.0" in ryzenai_version:
512
- # For PyPI installation (1.5.0+), no environment setup needed
513
- return None
514
- elif "1.4.0" in ryzenai_version:
515
- # Legacy lemonade-install approach for 1.4.0
516
- if not os.path.exists(os.path.join(oga_path, "libs", "onnxruntime.dll")):
517
- raise RuntimeError(
518
- f"Cannot find libs/onnxruntime.dll in lib folder: {oga_path}"
519
- )
520
-
521
- # Save current state so they can be restored after inference.
522
- saved_state = {"cwd": os.getcwd(), "path": os.environ["PATH"]}
523
-
524
- # Setup NPU environment (cwd and path will be restored later)
525
- os.chdir(oga_path)
526
- os.environ["PATH"] = (
527
- os.path.join(oga_path, "libs") + os.pathsep + os.environ["PATH"]
528
- )
529
- return saved_state
530
- else:
531
- raise ValueError(f"Unsupported RyzenAI version: {ryzenai_version}")
532
-
533
465
  @staticmethod
534
466
  def _load_model_and_setup_state(
535
467
  state, full_model_path, checkpoint, trust_remote_code
@@ -702,8 +634,7 @@ class OgaLoad(FirstTool):
702
634
  state.save_stat(Keys.CHECKPOINT, checkpoint)
703
635
  state.save_stat(Keys.LOCAL_MODEL_FOLDER, full_model_path)
704
636
  # See if there is a file ending in ".onnx" in this folder
705
- dir = os.listdir(input)
706
- has_onnx_file = any([filename.endswith(".onnx") for filename in dir])
637
+ has_onnx_file = find_onnx_files_recursively(input)
707
638
  if not has_onnx_file:
708
639
  raise ValueError(
709
640
  f"The folder {input} does not contain an ONNX model file."
@@ -852,15 +783,10 @@ class OgaLoad(FirstTool):
852
783
 
853
784
  try:
854
785
  if device == "npu":
855
- saved_env_state = self._setup_npu_environment(
856
- ryzenai_version, oga_path
857
- )
858
786
  # Set USE_AIE_RoPE based on model type
859
787
  os.environ["USE_AIE_RoPE"] = (
860
788
  "0" if "phi-" in checkpoint.lower() else "1"
861
789
  )
862
- elif device == "hybrid":
863
- saved_env_state = None
864
790
 
865
791
  self._load_model_and_setup_state(
866
792
  state, full_model_path, checkpoint, trust_remote_code
@@ -0,0 +1,403 @@
1
+ """
2
+ Migration utilities for handling RyzenAI version upgrades.
3
+
4
+ This module provides functionality to detect and clean up incompatible RyzenAI models
5
+ when upgrading between major versions (e.g., 1.4/1.5 -> 1.6).
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import shutil
11
+ import logging
12
+ from typing import List, Dict, Optional, Tuple
13
+
14
+
15
+ def get_directory_size(path: str) -> int:
16
+ """
17
+ Calculate the total size of a directory in bytes.
18
+
19
+ Args:
20
+ path: Path to the directory
21
+
22
+ Returns:
23
+ Total size in bytes
24
+ """
25
+ total_size = 0
26
+ try:
27
+ for dirpath, _, filenames in os.walk(path):
28
+ for filename in filenames:
29
+ filepath = os.path.join(dirpath, filename)
30
+ try:
31
+ total_size += os.path.getsize(filepath)
32
+ except (OSError, FileNotFoundError):
33
+ # Skip files that can't be accessed
34
+ pass
35
+ except (OSError, FileNotFoundError):
36
+ pass
37
+ return total_size
38
+
39
+
40
+ def format_size(size_bytes: int) -> str:
41
+ """
42
+ Format byte size to human-readable string.
43
+
44
+ Args:
45
+ size_bytes: Size in bytes
46
+
47
+ Returns:
48
+ Formatted string (e.g., "1.5 GB", "450 MB")
49
+ """
50
+ for unit in ["B", "KB", "MB"]:
51
+ if size_bytes < 1024.0:
52
+ return f"{size_bytes:.1f} {unit}"
53
+ size_bytes /= 1024.0
54
+ return f"{size_bytes:.1f} GB"
55
+
56
+
57
+ def check_rai_config_version(model_path: str, required_version: str = "1.6.0") -> bool:
58
+ """
59
+ Check if a model's rai_config.json contains the required version.
60
+
61
+ Args:
62
+ model_path: Path to the model directory
63
+ required_version: Version string to check for (default: "1.6.0")
64
+
65
+ Returns:
66
+ True if model is compatible (has required version), False otherwise
67
+ """
68
+ rai_config_path = os.path.join(model_path, "rai_config.json")
69
+
70
+ # If no rai_config.json exists, it's not a RyzenAI model
71
+ if not os.path.exists(rai_config_path):
72
+ return True
73
+
74
+ try:
75
+ with open(rai_config_path, "r", encoding="utf-8") as f:
76
+ config = json.load(f)
77
+
78
+ # Check if max_prompt_length exists and has the required version
79
+ if "max_prompt_length" in config:
80
+ max_prompt_length = config["max_prompt_length"]
81
+ if isinstance(max_prompt_length, dict):
82
+ # If it's a dict with version keys, check for required version
83
+ return required_version in max_prompt_length
84
+ # Fallback to True to avoid deleting models if format changes
85
+ return True
86
+
87
+ return True
88
+
89
+ except (json.JSONDecodeError, OSError) as e:
90
+ logging.warning(f"Could not read rai_config.json from {model_path}: {e}")
91
+ # If we can't read it, assume it's compatible to avoid false positives
92
+ return True
93
+
94
+
95
+ def scan_oga_models_cache(cache_dir: str) -> List[Dict[str, any]]:
96
+ """
97
+ Scan the Lemonade OGA models cache for incompatible models.
98
+
99
+ Args:
100
+ cache_dir: Path to the Lemonade cache directory
101
+
102
+ Returns:
103
+ List of dicts with model info (path, name, size, compatible)
104
+ """
105
+ oga_models_path = os.path.join(cache_dir, "oga_models")
106
+ incompatible_models = []
107
+
108
+ if not os.path.exists(oga_models_path):
109
+ return incompatible_models
110
+
111
+ try:
112
+ # Iterate through model directories in oga_models
113
+ for model_name in os.listdir(oga_models_path):
114
+ model_dir = os.path.join(oga_models_path, model_name)
115
+
116
+ if not os.path.isdir(model_dir):
117
+ continue
118
+
119
+ # Check all subdirectories (e.g., npu-int4, hybrid-int4)
120
+ for subdir in os.listdir(model_dir):
121
+ subdir_path = os.path.join(model_dir, subdir)
122
+
123
+ if not os.path.isdir(subdir_path):
124
+ continue
125
+
126
+ # Check if this model version is compatible
127
+ if not check_rai_config_version(subdir_path):
128
+ size = get_directory_size(subdir_path)
129
+ incompatible_models.append(
130
+ {
131
+ "path": subdir_path,
132
+ "name": f"{model_name}/{subdir}",
133
+ "size": size,
134
+ "size_formatted": format_size(size),
135
+ "cache_type": "lemonade",
136
+ }
137
+ )
138
+
139
+ except (OSError, PermissionError) as e:
140
+ logging.warning(f"Error scanning oga_models cache: {e}")
141
+
142
+ return incompatible_models
143
+
144
+
145
+ def scan_huggingface_cache(hf_home: Optional[str] = None) -> List[Dict[str, any]]:
146
+ """
147
+ Scan the HuggingFace cache for incompatible RyzenAI models.
148
+
149
+ Args:
150
+ hf_home: Path to HuggingFace home directory (default: from env or ~/.cache/huggingface)
151
+
152
+ Returns:
153
+ List of dicts with model info (path, name, size, compatible)
154
+ """
155
+ if hf_home is None:
156
+ hf_home = os.environ.get(
157
+ "HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface")
158
+ )
159
+
160
+ hub_path = os.path.join(hf_home, "hub")
161
+ incompatible_models = []
162
+
163
+ if not os.path.exists(hub_path):
164
+ return incompatible_models
165
+
166
+ try:
167
+ # Iterate through model directories in HuggingFace cache
168
+ for item in os.listdir(hub_path):
169
+ if not item.startswith("models--"):
170
+ continue
171
+
172
+ model_dir = os.path.join(hub_path, item)
173
+ if not os.path.isdir(model_dir):
174
+ continue
175
+
176
+ # Look in snapshots subdirectory
177
+ snapshots_dir = os.path.join(model_dir, "snapshots")
178
+ if not os.path.exists(snapshots_dir):
179
+ continue
180
+
181
+ # Check each snapshot
182
+ for snapshot_hash in os.listdir(snapshots_dir):
183
+ snapshot_path = os.path.join(snapshots_dir, snapshot_hash)
184
+
185
+ if not os.path.isdir(snapshot_path):
186
+ continue
187
+
188
+ # Check if this snapshot has incompatible RyzenAI model
189
+ if not check_rai_config_version(snapshot_path):
190
+ # Extract readable model name from directory
191
+ model_name = item.replace("models--", "").replace("--", "/")
192
+ size = get_directory_size(
193
+ model_dir
194
+ ) # Size of entire model directory
195
+ incompatible_models.append(
196
+ {
197
+ "path": model_dir,
198
+ "name": model_name,
199
+ "size": size,
200
+ "size_formatted": format_size(size),
201
+ "cache_type": "huggingface",
202
+ }
203
+ )
204
+ break
205
+
206
+ except (OSError, PermissionError) as e:
207
+ logging.warning(f"Error scanning HuggingFace cache: {e}")
208
+
209
+ return incompatible_models
210
+
211
+
212
+ def detect_incompatible_ryzenai_models(
213
+ cache_dir: str, hf_home: Optional[str] = None
214
+ ) -> Tuple[List[Dict[str, any]], int]:
215
+ """
216
+ Detect all incompatible RyzenAI models in both Lemonade and HuggingFace caches.
217
+
218
+ Args:
219
+ cache_dir: Path to the Lemonade cache directory
220
+ hf_home: Path to HuggingFace home directory (optional)
221
+
222
+ Returns:
223
+ Tuple of (list of incompatible models, total size in bytes)
224
+ """
225
+ incompatible_models = []
226
+
227
+ # Scan Lemonade cache
228
+ oga_models = scan_oga_models_cache(cache_dir)
229
+ incompatible_models.extend(oga_models)
230
+
231
+ # Scan HuggingFace cache
232
+ hf_models = scan_huggingface_cache(hf_home)
233
+ incompatible_models.extend(hf_models)
234
+
235
+ # Calculate total size
236
+ total_size = sum(model["size"] for model in incompatible_models)
237
+
238
+ logging.info(
239
+ f"Found {len(incompatible_models)} incompatible RyzenAI models "
240
+ f"({format_size(total_size)} total)"
241
+ )
242
+
243
+ return incompatible_models, total_size
244
+
245
+
246
+ def delete_model_directory(model_path: str) -> bool:
247
+ """
248
+ Safely delete a model directory.
249
+
250
+ Args:
251
+ model_path: Path to the model directory to delete
252
+
253
+ Returns:
254
+ True if deletion successful, False otherwise
255
+ """
256
+ try:
257
+ if os.path.exists(model_path):
258
+ shutil.rmtree(model_path)
259
+ logging.info(f"Deleted model directory: {model_path}")
260
+ return True
261
+ else:
262
+ logging.warning(f"Model directory not found: {model_path}")
263
+ return False
264
+ except (OSError, PermissionError) as e:
265
+ logging.error(f"Failed to delete model directory {model_path}: {e}")
266
+ return False
267
+
268
+
269
+ def _extract_checkpoint_from_path(path: str) -> Optional[str]:
270
+ """
271
+ Extract the checkpoint name from a model path.
272
+
273
+ Args:
274
+ path: Model directory path (either Lemonade cache or HuggingFace cache)
275
+
276
+ Returns:
277
+ Checkpoint name (e.g., "amd/Qwen2.5-1.5B-Instruct-awq") or None if not extractable
278
+ """
279
+ # Normalize path separators to handle both Unix and Windows paths
280
+ normalized_path = path.replace("\\", "/")
281
+ parts = normalized_path.split("/")
282
+
283
+ # Handle HuggingFace cache paths: models--{org}--{repo}
284
+ if "models--" in normalized_path:
285
+ for part in parts:
286
+ if part.startswith("models--"):
287
+ # Convert models--org--repo to org/repo
288
+ # Replace first two occurrences of -- with /
289
+ checkpoint = part.replace("models--", "", 1).replace("--", "/", 1)
290
+ return checkpoint
291
+ return None
292
+
293
+ # Handle Lemonade cache paths: oga_models/{model_name}/{device}-{dtype}
294
+ if "oga_models" in normalized_path:
295
+ try:
296
+ oga_models_idx = parts.index("oga_models")
297
+ if oga_models_idx + 1 < len(parts):
298
+ model_name = parts[oga_models_idx + 1]
299
+ # Convert model_name back to checkpoint (e.g., amd_model -> amd/model)
300
+ # This is a heuristic - we look for the pattern {org}_{model}
301
+ checkpoint = model_name.replace("_", "/", 1)
302
+ return checkpoint
303
+ except (ValueError, IndexError):
304
+ return None
305
+
306
+ return None
307
+
308
+
309
+ def _cleanup_user_models_json(deleted_checkpoints: List[str], user_models_file: str):
310
+ """
311
+ Remove entries from user_models.json for models that have been deleted.
312
+
313
+ Args:
314
+ deleted_checkpoints: List of checkpoint names that were deleted
315
+ user_models_file: Path to user_models.json
316
+ """
317
+ if not deleted_checkpoints or not os.path.exists(user_models_file):
318
+ return
319
+
320
+ try:
321
+ with open(user_models_file, "r", encoding="utf-8") as f:
322
+ user_models = json.load(f)
323
+
324
+ # Track which models to remove
325
+ models_to_remove = []
326
+ for model_name, model_info in user_models.items():
327
+ checkpoint = model_info.get("checkpoint", "")
328
+ # Check if this checkpoint matches any deleted checkpoints
329
+ # We do a case-insensitive comparison since paths may have been lowercased
330
+ for deleted_checkpoint in deleted_checkpoints:
331
+ if checkpoint.lower() == deleted_checkpoint.lower():
332
+ models_to_remove.append(model_name)
333
+ break
334
+
335
+ # Remove the models
336
+ for model_name in models_to_remove:
337
+ del user_models[model_name]
338
+ logging.info(f"Removed {model_name} from user_models.json")
339
+
340
+ # Save the updated file only if we removed something
341
+ if models_to_remove:
342
+ with open(user_models_file, "w", encoding="utf-8") as f:
343
+ json.dump(user_models, f, indent=2)
344
+ logging.info(
345
+ f"Updated user_models.json - removed {len(models_to_remove)} entries"
346
+ )
347
+
348
+ except (json.JSONDecodeError, OSError) as e:
349
+ logging.warning(f"Could not update user_models.json: {e}")
350
+
351
+
352
+ def delete_incompatible_models(
353
+ model_paths: List[str], user_models_file: Optional[str] = None
354
+ ) -> Dict[str, any]:
355
+ """
356
+ Delete multiple incompatible model directories and clean up user_models.json.
357
+
358
+ Args:
359
+ model_paths: List of paths to delete
360
+ user_models_file: Path to user_models.json (optional, will use default if not provided)
361
+
362
+ Returns:
363
+ Dict with deletion results (success_count, failed_count, freed_size, cleaned_user_models)
364
+ """
365
+ success_count = 0
366
+ failed_count = 0
367
+ freed_size = 0
368
+ deleted_checkpoints = []
369
+
370
+ for path in model_paths:
371
+ # Calculate size before deletion
372
+ size = get_directory_size(path)
373
+
374
+ # Extract checkpoint name before deleting
375
+ checkpoint = _extract_checkpoint_from_path(path)
376
+ if checkpoint:
377
+ deleted_checkpoints.append(checkpoint)
378
+
379
+ if delete_model_directory(path):
380
+ success_count += 1
381
+ freed_size += size
382
+ else:
383
+ failed_count += 1
384
+
385
+ # Clean up user_models.json if we deleted any models
386
+ cleaned_user_models = False
387
+ if deleted_checkpoints:
388
+ # Use default path if not provided
389
+ if user_models_file is None:
390
+ from lemonade.cache import DEFAULT_CACHE_DIR
391
+
392
+ user_models_file = os.path.join(DEFAULT_CACHE_DIR, "user_models.json")
393
+
394
+ _cleanup_user_models_json(deleted_checkpoints, user_models_file)
395
+ cleaned_user_models = True
396
+
397
+ return {
398
+ "success_count": success_count,
399
+ "failed_count": failed_count,
400
+ "freed_size": freed_size,
401
+ "freed_size_formatted": format_size(freed_size),
402
+ "cleaned_user_models": cleaned_user_models,
403
+ }