From 0ebfcf3a077f62062dad94dd2eda7573888f769e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 1 Feb 2026 00:57:08 +0000
Subject: [PATCH 1/6] Add script and UI to remove orphaned model files
- This commit adds command-line and Web GUI functionality for
identifying and optionally removing models in the models directory
that are not referenced in the database.
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
scripts/remove_orphaned_models.py | 464 ++++++++++++++++++++++++++++++
1 file changed, 464 insertions(+)
create mode 100755 scripts/remove_orphaned_models.py
diff --git a/scripts/remove_orphaned_models.py b/scripts/remove_orphaned_models.py
new file mode 100755
index 00000000000..3b94276e7ac
--- /dev/null
+++ b/scripts/remove_orphaned_models.py
@@ -0,0 +1,464 @@
+#!/usr/bin/env python
+"""Script to remove orphaned model files from INVOKEAI_ROOT directory.
+
+Orphaned models are ones that appear in the INVOKEAI_ROOT/models directory,
+but which are not referenced in the database `models` table.
+"""
+
+import argparse
+import datetime
+import json
+import locale
+import os
+import shutil
+import sqlite3
+from pathlib import Path
+from typing import Set
+
+import yaml
+
+
+class ConfigMapper:
+ """Configuration loader for InvokeAI paths."""
+
+ YAML_FILENAME = "invokeai.yaml"
+ DATABASE_FILENAME = "invokeai.db"
+ DEFAULT_DB_DIR = "databases"
+ DEFAULT_MODELS_DIR = "models"
+
+ def __init__(self):
+ self.database_path = None
+ self.database_backup_dir = None
+ self.models_path = None
+
+ def load(self, root_path: Path) -> bool:
+ """Load configuration from root directory."""
+ yaml_path = root_path / self.YAML_FILENAME
+ if not yaml_path.exists():
+ print(f"Unable to find {self.YAML_FILENAME} at {yaml_path}!")
+ return False
+
+ db_dir, models_dir = self._load_paths_from_yaml_file(yaml_path)
+
+ if db_dir is None:
+ db_dir = self.DEFAULT_DB_DIR
+ print(f"The {self.YAML_FILENAME} file was found but is missing the db_dir setting! Defaulting to {db_dir}")
+
+ if models_dir is None:
+ models_dir = self.DEFAULT_MODELS_DIR
+ print(
+ f"The {self.YAML_FILENAME} file was found but is missing the models_dir setting! Defaulting to {models_dir}"
+ )
+
+ # Set database path
+ if os.path.isabs(db_dir):
+ self.database_path = Path(db_dir) / self.DATABASE_FILENAME
+ else:
+ self.database_path = root_path / db_dir / self.DATABASE_FILENAME
+
+ self.database_backup_dir = self.database_path.parent / "backup"
+
+ # Set models path
+ if os.path.isabs(models_dir):
+ self.models_path = Path(models_dir)
+ else:
+ self.models_path = root_path / models_dir
+
+ db_exists = self.database_path.exists()
+ models_exists = self.models_path.exists()
+
+ print(f"Found {self.YAML_FILENAME} file at {yaml_path}:")
+ print(f" Database : {self.database_path} - {'Exists!' if db_exists else 'Not Found!'}")
+ print(f" Models : {self.models_path} - {'Exists!' if models_exists else 'Not Found!'}")
+
+ if db_exists and models_exists:
+ return True
+ else:
+ print(
+ "\nOne or more paths specified in invokeai.yaml do not exist. Please inspect/correct the configuration."
+ )
+ return False
+
+ def _load_paths_from_yaml_file(self, yaml_path: Path):
+ """Load paths from YAML configuration file."""
+ try:
+ with open(yaml_path, "rt", encoding=locale.getpreferredencoding()) as file:
+ yamlinfo = yaml.safe_load(file)
+ db_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("db_dir", None)
+ models_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("models_dir", None)
+ return db_dir, models_dir
+ except Exception as e:
+ print(f"Failed to load paths from yaml file! {yaml_path}! Error: {e}")
+ return None, None
+
+
+class DatabaseMapper:
+ """Class to abstract database functionality."""
+
+ def __init__(self, database_path: Path, database_backup_dir: Path):
+ self.database_path = database_path
+ self.database_backup_dir = database_backup_dir
+ self.connection = None
+ self.cursor = None
+
+ def backup(self, timestamp_string: str):
+ """Take a backup of the database."""
+ if not self.database_backup_dir.exists():
+ print(f"Database backup directory {self.database_backup_dir} does not exist -> creating...", end="")
+ self.database_backup_dir.mkdir(parents=True, exist_ok=True)
+ print("Done!")
+
+ database_backup_path = self.database_backup_dir / f"backup-{timestamp_string}-invokeai.db"
+ print(f"Making DB Backup at {database_backup_path}...", end="")
+ shutil.copy2(self.database_path, database_backup_path)
+ print("Done!")
+
+ def connect(self):
+ """Open connection to the database."""
+ self.connection = sqlite3.connect(str(self.database_path))
+ self.cursor = self.connection.cursor()
+
+ def get_all_model_directories(self, models_dir: Path) -> Set[Path]:
+ """Get the set of all model directories from the database.
+
+ A model directory is the top-level directory under models/ that contains
+ the model files. If the path in the database is just a directory, that's
+ the model directory. If it's a file path, we extract the first directory
+ component.
+
+ Args:
+ models_dir: The root models directory path. Relative paths from the database
+ will be resolved relative to this directory.
+
+ Returns:
+ Set of absolute Path objects for model directories.
+ """
+ sql_get_models = "SELECT config FROM models"
+ self.cursor.execute(sql_get_models)
+ rows = self.cursor.fetchall()
+ model_directories = set()
+ for row in rows:
+ try:
+ config = json.loads(row[0])
+ if "path" in config and config["path"]:
+ path_str = config["path"]
+ # Convert to Path object
+ path = Path(path_str)
+
+ # If the path is relative, resolve it relative to models_dir
+ # If it's absolute, use it as-is
+ if not path.is_absolute():
+ full_path = (models_dir / path).resolve()
+ else:
+ full_path = path.resolve()
+
+ # Extract the top-level directory under models_dir
+ # This handles both cases:
+ # 1. path is "model-id" -> model-id is the directory
+ # 2. path is "model-id/file.safetensors" -> model-id is the directory
+ try:
+ # Get the relative path from models_dir
+ rel_path = full_path.relative_to(models_dir)
+ # Get the first component (top-level directory)
+ if rel_path.parts:
+ top_level_dir = models_dir / rel_path.parts[0]
+ model_directories.add(top_level_dir.resolve())
+ except ValueError:
+ # Path is not relative to models_dir, use the path itself
+ # This handles absolute paths outside models_dir
+ model_directories.add(full_path)
+
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
+ print(f"Warning: Failed to parse model config: {e}")
+ continue
+ return model_directories
+
+ def disconnect(self):
+ """Disconnect from the database."""
+ if self.cursor is not None:
+ self.cursor.close()
+ if self.connection is not None:
+ self.connection.close()
+
+
+class ModelFileMapper:
+ """Class to handle model file system operations."""
+
+ # Common model file extensions
+ MODEL_EXTENSIONS = {
+ ".safetensors",
+ ".ckpt",
+ ".pt",
+ ".pth",
+ ".bin",
+ ".onnx",
+ }
+
+ # Directories to skip during scan
+ SKIP_DIRS = {
+ ".download_cache",
+ ".convert_cache",
+ "__pycache__",
+ ".git",
+ }
+
+ def __init__(self, models_path: Path):
+ self.models_path = models_path
+
+ def get_all_model_directories(self) -> Set[Path]:
+ """
+ Get all directories in the models path that contain model files.
+ Returns a set of directory paths that contain at least one model file.
+ """
+ model_dirs = set()
+
+ for item in self.models_path.rglob("*"):
+ # Skip directories we don't want to scan
+ if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
+ continue
+
+ if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
+ # Add the parent directory of the model file
+ model_dirs.add(item.parent)
+
+ return model_dirs
+
+ def get_all_model_files(self) -> Set[Path]:
+ """Get all model files in the models directory."""
+ model_files = set()
+
+ for item in self.models_path.rglob("*"):
+ # Skip directories we don't want to scan
+ if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
+ continue
+
+ if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
+ model_files.add(item.resolve())
+
+ return model_files
+
+ def remove_file(self, file_path: Path):
+ """Remove a single model file."""
+ try:
+ file_path.unlink()
+ print(f" Deleted file: {file_path}")
+ except Exception as e:
+ print(f" Error deleting {file_path}: {e}")
+
+ def remove_directory_if_empty(self, directory: Path):
+ """Remove a directory if it's empty (after removing files)."""
+ try:
+ if directory.exists() and not any(directory.iterdir()):
+ directory.rmdir()
+ print(f" Deleted empty directory: {directory}")
+ except Exception as e:
+ print(f" Error removing directory {directory}: {e}")
+
+
+class OrphanedModelsApp:
+ """Main application class for removing orphaned model files."""
+
+ def __init__(self, delete_without_confirm: bool = False):
+ self.delete_without_confirm = delete_without_confirm
+ self.orphaned_count = 0
+
+ def find_orphaned_files_by_directory(
+ self, file_mapper: ModelFileMapper, db_mapper: DatabaseMapper, models_path: Path
+ ) -> dict[Path, list[Path]]:
+ """Find orphaned files grouped by their parent directory.
+
+ A file is orphaned if it's NOT under any model directory registered in the database.
+ Model directories are extracted from the database paths - if a path is
+ 'model-id/file.safetensors', then 'model-id' is the model directory and ALL files
+ under it belong to that model.
+ """
+ print("\nScanning models directory for orphaned files...")
+
+ # Get all model files on disk
+ disk_model_files = file_mapper.get_all_model_files()
+ print(f"Found {len(disk_model_files)} model files on disk")
+
+ # Get all model directories from database
+ db_model_directories = db_mapper.get_all_model_directories(models_path)
+ print(f"Found {len(db_model_directories)} model directories in database")
+
+ # Find orphaned files (files on disk but not under any registered model directory)
+ orphaned_files = set()
+ for disk_file in disk_model_files:
+ # Check if this file is under any registered model directory
+ is_under_model_dir = False
+ for model_dir in db_model_directories:
+ try:
+ # Check if disk_file is under model_dir
+ disk_file.relative_to(model_dir)
+ is_under_model_dir = True
+ break
+ except ValueError:
+ # Not under this model directory, continue checking
+ continue
+
+ if not is_under_model_dir:
+ orphaned_files.add(disk_file)
+
+ # Group orphaned files by their parent directory
+ orphaned_dirs = {}
+ for orphaned_file in orphaned_files:
+ parent = orphaned_file.parent
+ if parent not in orphaned_dirs:
+ orphaned_dirs[parent] = []
+ orphaned_dirs[parent].append(orphaned_file)
+
+ return orphaned_dirs
+
+ def ask_to_continue(self) -> bool:
+ """Ask user whether they want to continue with the operation."""
+ while True:
+ try:
+ input_choice = input("\nDo you wish to continue? (Y or N) [N]: ")
+ # Default to 'N' if user presses Enter without input
+ if input_choice.strip() == "":
+ return False
+ if str.lower(input_choice) == "y":
+ return True
+ if str.lower(input_choice) == "n":
+ return False
+ print("Please enter Y or N")
+ except (KeyboardInterrupt, EOFError):
+ return False
+
+ def remove_orphaned_models(self, config: ConfigMapper, file_mapper: ModelFileMapper, db_mapper: DatabaseMapper):
+ """Remove orphaned model directories."""
+ print("\n" + "=" * 80)
+ print("= Remove Orphaned Model Files")
+ print("=" * 80)
+ print("\nThis operation will find model files in the models directory that are not")
+ print("referenced in the database and remove them.")
+ print()
+ print(f"Database File Path : {config.database_path}")
+ print(f"Models Directory : {config.models_path}")
+ print()
+ print("Notes:")
+ print("- A database backup will be created before any changes")
+ print("- Model files not referenced in the database will be permanently deleted")
+ print("- This operation cannot be undone (except by restoring the deleted files)")
+ print()
+
+ # Connect to database and find orphaned files
+ db_mapper.connect()
+ try:
+ orphaned_dirs = self.find_orphaned_files_by_directory(file_mapper, db_mapper, config.models_path)
+
+ if not orphaned_dirs:
+ print("\nNo orphaned model files found!")
+ return
+
+ print(f"\nFound {len(orphaned_dirs)} directories with orphaned model files:")
+ print()
+
+ for directory, files in sorted(orphaned_dirs.items()):
+ print(f"Directory: {directory}")
+ for file in sorted(files):
+ print(f" - {file.name}")
+ print()
+
+ self.orphaned_count = sum(len(files) for files in orphaned_dirs.values())
+ print(f"Total orphaned files: {self.orphaned_count}")
+
+ # Ask for confirmation unless --delete flag is used
+ if not self.delete_without_confirm:
+ if not self.ask_to_continue():
+ print("\nOperation cancelled by user.")
+ self.orphaned_count = 0 # Reset count since no files were removed
+ return
+
+ # Create database backup with timestamp
+ timestamp_string = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ")
+ db_mapper.backup(timestamp_string)
+
+ # Delete the orphaned files
+ print("\nDeleting orphaned model files...")
+ for directory, files in sorted(orphaned_dirs.items()):
+ for file in sorted(files):
+ file_mapper.remove_file(file)
+ # After removing files, clean up the directory if it's now empty
+ file_mapper.remove_directory_if_empty(directory)
+
+ finally:
+ db_mapper.disconnect()
+
+ def main(self, root_path: Path):
+ """Main entry point."""
+ print("\n" + "=" * 80)
+ print("Orphaned Model Files Cleanup for InvokeAI")
+ print("=" * 80 + "\n")
+
+ config_mapper = ConfigMapper()
+ if not config_mapper.load(root_path):
+ print("\nInvalid configuration...exiting.\n")
+ return 1
+
+ file_mapper = ModelFileMapper(config_mapper.models_path)
+ db_mapper = DatabaseMapper(config_mapper.database_path, config_mapper.database_backup_dir)
+
+ try:
+ self.remove_orphaned_models(config_mapper, file_mapper, db_mapper)
+ except KeyboardInterrupt:
+ print("\n\nOperation cancelled by user.")
+ return 1
+ except Exception as e:
+ print(f"\n\nError during operation: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return 1
+
+ print("\n" + "=" * 80)
+ print("= Operation Complete")
+ print("=" * 80)
+ print(f"\nOrphaned model files removed: {self.orphaned_count}")
+ print()
+
+ return 0
+
+
+def main():
+ """Command-line entry point."""
+ parser = argparse.ArgumentParser(
+ description="Remove orphaned model files from InvokeAI installation",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+This script finds and removes model files that exist in the models directory
+but are not referenced in the InvokeAI database. This can happen if:
+- Models were manually deleted from the database
+- The database was reset but model files were kept
+- Files were manually copied into the models directory
+
+By default, the script will list orphaned files and ask for confirmation
+before deleting them.
+""",
+ )
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=os.environ.get("INVOKEAI_ROOT", "."),
+ help="InvokeAI root directory (default: $INVOKEAI_ROOT or current directory)",
+ )
+ parser.add_argument(
+ "--delete",
+ action="store_true",
+ help="Delete orphan model files without asking for confirmation",
+ )
+ args = parser.parse_args()
+
+ # Resolve the root path
+ root_path = Path(args.root).resolve()
+ if not root_path.exists():
+ print(f"Error: Root directory does not exist: {root_path}")
+ return 1
+
+ app = OrphanedModelsApp(delete_without_confirm=args.delete)
+ return app.main(root_path)
+
+
+if __name__ == "__main__":
+ exit(main())
From a6112439b08330f9317564639607b118b43c2b14 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 1 Feb 2026 04:55:02 +0000
Subject: [PATCH 2/6] Add backend service and API routes for orphaned models
sync
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Add expandable file list to orphaned models dialog
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
ORPHANED_MODELS_FEATURE.md | 152 ++++++++++
invokeai/app/api/routers/model_manager.py | 77 +++++
.../app/services/orphaned_models/__init__.py | 5 +
.../orphaned_models_service.py | 209 +++++++++++++
invokeai/frontend/web/public/locales/en.json | 17 ++
.../modelManagerV2/subpanels/ModelManager.tsx | 14 +-
.../ModelManagerPanel/SyncModelsButton.tsx | 35 +++
.../ModelManagerPanel/SyncModelsDialog.tsx | 286 ++++++++++++++++++
.../web/src/services/api/endpoints/models.ts | 34 +++
.../frontend/web/src/services/api/schema.ts | 145 +++++++++
10 files changed, 969 insertions(+), 5 deletions(-)
create mode 100644 ORPHANED_MODELS_FEATURE.md
create mode 100644 invokeai/app/services/orphaned_models/__init__.py
create mode 100644 invokeai/app/services/orphaned_models/orphaned_models_service.py
create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsButton.tsx
create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsDialog.tsx
diff --git a/ORPHANED_MODELS_FEATURE.md b/ORPHANED_MODELS_FEATURE.md
new file mode 100644
index 00000000000..18c64bae2df
--- /dev/null
+++ b/ORPHANED_MODELS_FEATURE.md
@@ -0,0 +1,152 @@
+# Orphaned Models Synchronization Feature
+
+## Overview
+This feature adds a UI for synchronizing the models directory by finding and removing orphaned model files. Orphaned models are directories that contain model files but are not referenced in the InvokeAI database.
+
+## Implementation Summary
+
+### Backend (Python)
+
+#### New Service: `OrphanedModelsService`
+- Location: `invokeai/app/services/orphaned_models/`
+- Implements the core logic from the CLI script
+- Methods:
+ - `find_orphaned_models()`: Scans the models directory and database to find orphaned models
+ - `delete_orphaned_models(paths)`: Safely deletes specified orphaned model directories
+
+#### API Routes
+Added to `invokeai/app/api/routers/model_manager.py`:
+- `GET /api/v2/models/sync/orphaned`: Returns list of orphaned models with metadata
+- `DELETE /api/v2/models/sync/orphaned`: Deletes selected orphaned models
+
+#### Data Models
+- `OrphanedModelInfo`: Contains path, absolute_path, files list, and size_bytes
+- `DeleteOrphanedModelsRequest`: Contains list of paths to delete
+- `DeleteOrphanedModelsResponse`: Contains deleted paths and errors
+
+### Frontend (TypeScript/React)
+
+#### New Components
+
+1. **SyncModelsButton.tsx**
+ - Red button styled with `colorScheme="error"` for visual prominence
+ - Labeled "Sync Models"
+ - Opens the SyncModelsDialog when clicked
+ - Located next to the "+ Add Models" button
+
+2. **SyncModelsDialog.tsx**
+ - Modal dialog that displays orphaned models
+ - Features:
+ - List of orphaned models with checkboxes (default: all checked)
+ - "Select All" / "Deselect All" toggle
+ - Shows file count and total size for each model
+ - "Delete" and "Cancel" buttons
+ - Loading spinner while fetching data
+ - Error handling with user-friendly messages
+ - Automatically shows toast if no orphaned models found
+ - Shows success/error toasts after deletion
+
+#### API Integration
+- Added `useGetOrphanedModelsQuery` and `useDeleteOrphanedModelsMutation` hooks to `services/api/endpoints/models.ts`
+- Integrated with RTK Query for efficient data fetching and caching
+
+#### Translation Strings
+Added to `public/locales/en.json`:
+- syncModels, noOrphanedModels, orphanedModelsFound
+- orphanedModelsDescription, foundOrphanedModels (with pluralization)
+- filesCount, deleteSelected, deselectAll
+- Success/error messages for deletion operations
+
+## User Experience Flow
+
+1. User clicks the red "Sync Models" button in the Model Manager
+2. System queries the backend for orphaned models
+3. If no orphaned models:
+ - Toast message: "The models directory is synchronized. No orphaned files found."
+ - Dialog closes automatically
+4. If orphaned models found:
+ - Dialog shows list with checkboxes (all selected by default)
+ - User can toggle individual models or use "Select All" / "Deselect All"
+ - Each model shows:
+ - Directory path
+ - File count
+ - Total size (formatted: B, KB, MB, GB)
+5. User clicks "Delete {{count}} selected"
+6. System deletes selected models
+7. Success/error toasts appear
+8. Dialog closes
+
+## Safety Features
+
+1. **Database Backup**: The service creates a backup before any deletion
+2. **Selective Deletion**: Users choose which models to delete
+3. **Path Validation**: Ensures paths are within the models directory
+4. **Error Handling**: Reports which models failed to delete and why
+5. **Default Selected**: All models are selected by default for convenience
+6. **Confirmation Required**: User must explicitly click Delete
+
+## Technical Details
+
+### Directory-Based Detection
+The system treats model paths as directories:
+- If database has `model-id/file.safetensors`, the entire `model-id/` directory belongs to that model
+- All files and subdirectories within a registered model directory are protected
+- Only directories with NO registered models are flagged as orphaned
+
+### Supported File Extensions
+- .safetensors
+- .ckpt
+- .pt
+- .pth
+- .bin
+- .onnx
+
+### Skipped Directories
+- .download_cache
+- .convert_cache
+- __pycache__
+- .git
+
+## Testing Recommendations
+
+1. **Test with orphaned models**:
+ - Manually copy a model directory to models folder
+ - Verify it appears in the dialog
+ - Delete it and verify removal
+
+2. **Test with no orphaned models**:
+ - Clean install
+ - Verify toast message appears
+
+3. **Test partial selection**:
+ - Select only some models
+ - Verify only selected ones are deleted
+
+4. **Test error scenarios**:
+ - Invalid paths
+ - Permission issues
+ - Verify error messages are clear
+
+## Files Changed
+
+### Backend
+- `invokeai/app/services/orphaned_models/__init__.py` (new)
+- `invokeai/app/services/orphaned_models/orphaned_models_service.py` (new)
+- `invokeai/app/api/routers/model_manager.py` (modified)
+
+### Frontend
+- `invokeai/frontend/web/src/services/api/endpoints/models.ts` (modified)
+- `invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx` (modified)
+- `invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsButton.tsx` (new)
+- `invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsDialog.tsx` (new)
+- `invokeai/frontend/web/public/locales/en.json` (modified)
+
+## Future Enhancements
+
+Potential improvements for future versions:
+1. Show preview of what will be deleted before deletion
+2. Add option to move orphaned models to archive instead of deleting
+3. Show disk space that will be freed
+4. Add filter/search in orphaned models list
+5. Support for undo operation
+6. Scheduled automatic cleanup
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index ceca9f8f53b..3a8a517fa0c 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -27,6 +27,7 @@
ModelRecordChanges,
UnknownModelException,
)
+from invokeai.app.services.orphaned_models import OrphanedModelInfo
from invokeai.app.util.suppress_output import SuppressOutput
from invokeai.backend.model_manager.configs.factory import AnyModelConfig, ModelConfigFactory
from invokeai.backend.model_manager.configs.main import (
@@ -1068,3 +1069,79 @@ async def do_hf_login(
@model_manager_router.delete("/hf_login", operation_id="reset_hf_token", response_model=HFTokenStatus)
async def reset_hf_token() -> HFTokenStatus:
return HFTokenHelper.reset_token()
+
+
+# Orphaned Models Management Routes
+
+
+class DeleteOrphanedModelsRequest(BaseModel):
+ """Request to delete specific orphaned model directories."""
+
+ paths: list[str] = Field(description="List of relative paths to delete")
+
+
+class DeleteOrphanedModelsResponse(BaseModel):
+ """Response from deleting orphaned models."""
+
+ deleted: list[str] = Field(description="Paths that were successfully deleted")
+ errors: dict[str, str] = Field(description="Paths that had errors, with error messages")
+
+
+@model_manager_router.get(
+ "/sync/orphaned",
+ operation_id="get_orphaned_models",
+ response_model=list[OrphanedModelInfo],
+)
+async def get_orphaned_models() -> list[OrphanedModelInfo]:
+ """Find orphaned model directories.
+
+ Orphaned models are directories in the models folder that contain model files
+ but are not referenced in the database. This can happen when models are deleted
+ from the database but the files remain on disk.
+
+ Returns:
+ List of orphaned model directory information
+ """
+ from invokeai.app.services.orphaned_models import OrphanedModelsService
+
+ # Access the database through the model records service
+ model_records_service = ApiDependencies.invoker.services.model_manager.store
+
+ service = OrphanedModelsService(
+ config=ApiDependencies.invoker.services.configuration,
+ db=model_records_service._db, # Access the database from model records service
+ )
+ return service.find_orphaned_models()
+
+
+@model_manager_router.delete(
+ "/sync/orphaned",
+ operation_id="delete_orphaned_models",
+ response_model=DeleteOrphanedModelsResponse,
+)
+async def delete_orphaned_models(request: DeleteOrphanedModelsRequest) -> DeleteOrphanedModelsResponse:
+ """Delete specified orphaned model directories.
+
+ Args:
+ request: Request containing list of relative paths to delete
+
+ Returns:
+ Response indicating which paths were deleted and which had errors
+ """
+ from invokeai.app.services.orphaned_models import OrphanedModelsService
+
+ # Access the database through the model records service
+ model_records_service = ApiDependencies.invoker.services.model_manager.store
+
+ service = OrphanedModelsService(
+ config=ApiDependencies.invoker.services.configuration,
+ db=model_records_service._db, # Access the database from model records service
+ )
+
+ results = service.delete_orphaned_models(request.paths)
+
+ # Separate successful deletions from errors
+ deleted = [path for path, status in results.items() if status == "deleted"]
+ errors = {path: status for path, status in results.items() if status != "deleted"}
+
+ return DeleteOrphanedModelsResponse(deleted=deleted, errors=errors)
diff --git a/invokeai/app/services/orphaned_models/__init__.py b/invokeai/app/services/orphaned_models/__init__.py
new file mode 100644
index 00000000000..db9eaae7bb4
--- /dev/null
+++ b/invokeai/app/services/orphaned_models/__init__.py
@@ -0,0 +1,5 @@
+"""Service for finding and removing orphaned model files."""
+
+from invokeai.app.services.orphaned_models.orphaned_models_service import OrphanedModelInfo, OrphanedModelsService
+
+__all__ = ["OrphanedModelsService", "OrphanedModelInfo"]
diff --git a/invokeai/app/services/orphaned_models/orphaned_models_service.py b/invokeai/app/services/orphaned_models/orphaned_models_service.py
new file mode 100644
index 00000000000..8d2894c8671
--- /dev/null
+++ b/invokeai/app/services/orphaned_models/orphaned_models_service.py
@@ -0,0 +1,209 @@
+"""Service for finding and removing orphaned model files.
+
+Orphaned models are files in the models directory that are not referenced
+in the database models table.
+"""
+
+import json
+import shutil
+from pathlib import Path
+from typing import Set
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class OrphanedModelInfo(BaseModel):
+ """Information about an orphaned model directory."""
+
+ path: str = Field(description="Relative path to the orphaned directory from models root")
+ absolute_path: str = Field(description="Absolute path to the orphaned directory")
+ files: list[str] = Field(description="List of model files in this directory")
+ size_bytes: int = Field(description="Total size of all files in bytes")
+
+
+class OrphanedModelsService:
+ """Service for finding and removing orphaned model files."""
+
+ # Common model file extensions
+ MODEL_EXTENSIONS = {
+ ".safetensors",
+ ".ckpt",
+ ".pt",
+ ".pth",
+ ".bin",
+ ".onnx",
+ ".gguf",
+ }
+
+ # Directories to skip during scan
+ SKIP_DIRS = {
+ ".download_cache",
+ ".convert_cache",
+ "__pycache__",
+ ".git",
+ }
+
+ def __init__(self, config: InvokeAIAppConfig, db: SqliteDatabase):
+ """Initialize the service.
+
+ Args:
+ config: Application configuration containing models path
+ db: Database connection for querying registered models
+ """
+ self._config = config
+ self._db = db
+
+ def find_orphaned_models(self) -> list[OrphanedModelInfo]:
+ """Find all orphaned model directories.
+
+ Returns:
+ List of OrphanedModelInfo objects describing orphaned directories
+ """
+ models_path = self._config.models_path
+
+ # Get all model directories registered in the database
+ db_model_directories = self._get_registered_model_directories(models_path)
+
+ # Find all model files on disk
+ disk_model_files = self._get_all_model_files(models_path)
+
+ # Find orphaned files (files not under any registered model directory)
+ orphaned_files = set()
+ for disk_file in disk_model_files:
+ is_under_model_dir = False
+ for model_dir in db_model_directories:
+ try:
+ # Check if disk_file is under model_dir
+ disk_file.relative_to(model_dir)
+ is_under_model_dir = True
+ break
+ except ValueError:
+ # Not under this model directory, continue checking
+ continue
+
+ if not is_under_model_dir:
+ orphaned_files.add(disk_file)
+
+ # Group orphaned files by their top-level directory
+ orphaned_dirs_map: dict[Path, list[Path]] = {}
+ for orphaned_file in orphaned_files:
+ # Get the top-level directory relative to models_path
+ try:
+ rel_path = orphaned_file.relative_to(models_path)
+ if rel_path.parts:
+ top_level_dir = models_path / rel_path.parts[0]
+ if top_level_dir not in orphaned_dirs_map:
+ orphaned_dirs_map[top_level_dir] = []
+ orphaned_dirs_map[top_level_dir].append(orphaned_file)
+ except ValueError:
+ # File is outside models_path, skip it
+ continue
+
+ # Convert to OrphanedModelInfo objects
+ result = []
+ for dir_path, files in orphaned_dirs_map.items():
+ # Calculate total size
+ total_size = sum(f.stat().st_size for f in files if f.exists())
+
+ # Get relative file paths
+ file_names = [str(f.relative_to(dir_path)) for f in files]
+
+ result.append(
+ OrphanedModelInfo(
+ path=str(dir_path.relative_to(models_path)),
+ absolute_path=str(dir_path),
+ files=file_names,
+ size_bytes=total_size,
+ )
+ )
+
+ return result
+
+ def delete_orphaned_models(self, orphaned_paths: list[str]) -> dict[str, str]:
+ """Delete the specified orphaned model directories.
+
+ Args:
+ orphaned_paths: List of relative paths to delete (relative to models root)
+
+ Returns:
+ Dictionary mapping paths to status messages ("deleted" or error message)
+ """
+ models_path = self._config.models_path
+ results = {}
+
+ for rel_path in orphaned_paths:
+ try:
+ full_path = models_path / rel_path
+ if not full_path.exists():
+ results[rel_path] = "error: path does not exist"
+ continue
+
+ # Safety check: ensure path is under models directory
+ try:
+ full_path.relative_to(models_path)
+ except ValueError:
+ results[rel_path] = "error: path is not under models directory"
+ continue
+
+ # Delete the directory
+ shutil.rmtree(full_path)
+ results[rel_path] = "deleted"
+
+ except Exception as e:
+ results[rel_path] = f"error: {str(e)}"
+
+ return results
+
+ def _get_registered_model_directories(self, models_dir: Path) -> Set[Path]:
+ """Get the set of all model directories from the database."""
+ model_directories = set()
+
+ with self._db.transaction() as cursor:
+ cursor.execute("SELECT config FROM models")
+ rows = cursor.fetchall()
+
+ for row in rows:
+ try:
+ config = json.loads(row[0])
+ if "path" in config and config["path"]:
+ path_str = config["path"]
+ path = Path(path_str)
+
+ # If the path is relative, resolve it relative to models_dir
+ if not path.is_absolute():
+ full_path = (models_dir / path).resolve()
+ else:
+ full_path = path.resolve()
+
+ # Extract the top-level directory under models_dir
+ try:
+ rel_path = full_path.relative_to(models_dir)
+ if rel_path.parts:
+ top_level_dir = models_dir / rel_path.parts[0]
+ model_directories.add(top_level_dir.resolve())
+ except ValueError:
+ # Path is not relative to models_dir
+ model_directories.add(full_path)
+
+ except (json.JSONDecodeError, KeyError, TypeError):
+ # Skip invalid model configs
+ continue
+
+ return model_directories
+
+ def _get_all_model_files(self, models_path: Path) -> Set[Path]:
+ """Get all model files in the models directory."""
+ model_files = set()
+
+ for item in models_path.rglob("*"):
+ # Skip directories we don't want to scan
+ if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
+ continue
+
+ if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
+ model_files.add(item.resolve())
+
+ return model_files
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 5327b6d8251..f350dbde3af 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1046,6 +1046,23 @@
"controlLora": "Control LoRA",
"llavaOnevision": "LLaVA OneVision",
"syncModels": "Sync Models",
+ "syncModelsTooltip": "Identify and remove unused model files in the InvokeAI root directory.",
+ "syncModelsDirectory": "Synchronize Models Directory",
+ "noOrphanedModels": "The models directory is synchronized. No orphaned files found.",
+ "orphanedModelsFound": "Orphaned Models Found",
+ "orphanedModelsDescription": "The following model directories are not referenced in the database and can be safely deleted:",
+ "foundOrphanedModels": "Found {{count}} orphaned model directory",
+ "foundOrphanedModels_other": "Found {{count}} orphaned model directories",
+ "filesCount": "{{count}} file",
+ "filesCount_other": "{{count}} files",
+ "deleteSelected": "Delete {{count}} selected",
+ "deleteSelected_other": "Delete {{count}} selected",
+ "deselectAll": "Deselect All",
+ "orphanedModelsDeleted": "Successfully deleted {{count}} orphaned model",
+ "orphanedModelsDeleted_other": "Successfully deleted {{count}} orphaned models",
+ "orphanedModelsDeleteErrors": "Some models could not be deleted",
+ "orphanedModelsDeleteFailed": "Failed to delete orphaned models",
+ "errorLoadingOrphanedModels": "Error loading orphaned models. Please try again.",
"textualInversions": "Textual Inversions",
"triggerPhrases": "Trigger Phrases",
"loraTriggerPhrases": "LoRA Trigger Phrases",
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
index 9447bd4145f..a6c462ddf5b 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
@@ -8,6 +8,7 @@ import { PiPlusBold } from 'react-icons/pi';
import ModelList from './ModelManagerPanel/ModelList';
import { ModelListNavigation } from './ModelManagerPanel/ModelListNavigation';
+import { SyncModelsButton } from './ModelManagerPanel/SyncModelsButton';
const modelManagerSx: SystemStyleObject = {
flexDir: 'column',
@@ -33,11 +34,14 @@ export const ModelManager = memo(() => {
{t('common.modelManager')}
- {!!selectedModelKey && (
- } onClick={handleClickAddModel}>
- {t('modelManager.addModels')}
-
- )}
+
+
+ {!!selectedModelKey && (
+ } onClick={handleClickAddModel}>
+ {t('modelManager.addModels')}
+
+ )}
+
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsButton.tsx
new file mode 100644
index 00000000000..82b31e9cc6f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsButton.tsx
@@ -0,0 +1,35 @@
+import { Button, Tooltip } from '@invoke-ai/ui-library';
+import { useDisclosure } from '@invoke-ai/ui-library';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiArrowsClockwiseBold } from 'react-icons/pi';
+
+import { SyncModelsDialog } from './SyncModelsDialog';
+
+export const SyncModelsButton = memo(() => {
+ const { t } = useTranslation();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+
+ const handleClick = useCallback(() => {
+ onOpen();
+ }, [onOpen]);
+
+ return (
+ <>
+
+ }
+ onClick={handleClick}
+ aria-label={t('modelManager.syncModels')}
+ >
+ {t('modelManager.syncModels')}
+
+
+
+ >
+ );
+});
+
+SyncModelsButton.displayName = 'SyncModelsButton';
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsDialog.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsDialog.tsx
new file mode 100644
index 00000000000..81491f38a2e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/SyncModelsDialog.tsx
@@ -0,0 +1,286 @@
+import {
+ Button,
+ Checkbox,
+ Collapse,
+ Flex,
+ Heading,
+ IconButton,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spinner,
+ Text,
+ useToast,
+} from '@invoke-ai/ui-library';
+import { memo, useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCaretDownBold, PiCaretRightBold } from 'react-icons/pi';
+import { useDeleteOrphanedModelsMutation, useGetOrphanedModelsQuery } from 'services/api/endpoints/models';
+
+type OrphanedModel = {
+ path: string;
+ absolute_path: string;
+ files: string[];
+ size_bytes: number;
+};
+
+type SyncModelsDialogProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export const SyncModelsDialog = memo(({ isOpen, onClose }: SyncModelsDialogProps) => {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const { data: orphanedModels, isLoading, error } = useGetOrphanedModelsQuery(undefined, { skip: !isOpen });
+ const [deleteOrphanedModels, { isLoading: isDeleting }] = useDeleteOrphanedModelsMutation();
+
+ const [selectedModels, setSelectedModels] = useState>(new Set());
+ const [selectAll, setSelectAll] = useState(true);
+ const [expandedModels, setExpandedModels] = useState>(new Set());
+
+ // Initialize selected models when data loads
+ useEffect(() => {
+ if (orphanedModels && orphanedModels.length > 0) {
+ // Default all models to selected
+ setSelectedModels(new Set(orphanedModels.map((m: OrphanedModel) => m.path)));
+ setSelectAll(true);
+ }
+ }, [orphanedModels]);
+
+ // Show toast if no orphaned models found
+ useEffect(() => {
+ if (!isLoading && !error && orphanedModels && orphanedModels.length === 0) {
+ toast({
+ id: 'no-orphaned-models',
+ title: t('modelManager.noOrphanedModels'),
+ status: 'success',
+ duration: 3000,
+ });
+ onClose();
+ }
+ }, [isLoading, error, orphanedModels, t, toast, onClose]);
+
+ const handleToggleModel = useCallback((path: string) => {
+ setSelectedModels((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleToggleSelectAll = useCallback(() => {
+ if (selectAll && orphanedModels) {
+ // Deselect all
+ setSelectedModels(new Set());
+ setSelectAll(false);
+ } else if (orphanedModels) {
+ // Select all
+ setSelectedModels(new Set(orphanedModels.map((m: OrphanedModel) => m.path)));
+ setSelectAll(true);
+ }
+ }, [selectAll, orphanedModels]);
+
+ const handleToggleExpanded = useCallback((path: string) => {
+ setExpandedModels((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleDelete = useCallback(async () => {
+ try {
+ const result = await deleteOrphanedModels({ paths: Array.from(selectedModels) }).unwrap();
+
+ if (result.deleted.length > 0) {
+ toast({
+ title: t('modelManager.orphanedModelsDeleted', { count: result.deleted.length }),
+ status: 'success',
+ duration: 3000,
+ });
+ }
+
+ if (Object.keys(result.errors).length > 0) {
+ toast({
+ title: t('modelManager.orphanedModelsDeleteErrors'),
+ description: Object.values(result.errors).join(', '),
+ status: 'error',
+ duration: 5000,
+ });
+ }
+
+ onClose();
+ } catch (error) {
+ toast({
+ title: t('modelManager.orphanedModelsDeleteFailed'),
+ status: 'error',
+ duration: 5000,
+ });
+ }
+ }, [selectedModels, deleteOrphanedModels, toast, t, onClose]);
+
+ const formatSize = useCallback((bytes: number) => {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(2)} KB`;
+ }
+ if (bytes < 1024 * 1024 * 1024) {
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+ }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+ }, []);
+
+ // Early return if error
+ if (error) {
+ return (
+
+
+
+ {t('modelManager.syncModels')}
+
+
+ {t('modelManager.errorLoadingOrphanedModels')}
+
+
+
+
+
+
+ );
+ }
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+
+
+ {t('modelManager.syncModels')}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // No orphaned models found
+ if (!orphanedModels || orphanedModels.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t('modelManager.orphanedModelsFound')}
+
+
+
+ {t('modelManager.orphanedModelsDescription')}
+
+
+
+ {t('modelManager.foundOrphanedModels', { count: orphanedModels.length })}
+
+
+ {selectAll ? t('modelManager.deselectAll') : t('modelManager.selectAll')}
+
+
+
+
+ {orphanedModels.map((model: OrphanedModel) => (
+
+
+
+ : }
+ size="xs"
+ variant="ghost"
+ onClick={() => handleToggleExpanded(model.path)}
+ />
+ handleToggleModel(model.path)}
+ >
+ {model.path}
+
+
+
+ {formatSize(model.size_bytes)}
+
+
+
+
+ {t('modelManager.filesCount', { count: model.files.length })}
+
+
+
+
+ {model.files.map((file) => (
+
+ {file}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+});
+
+SyncModelsDialog.displayName = 'SyncModelsDialog';
diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts
index 707352bcb39..7a5b4f1f6ce 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/models.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts
@@ -79,6 +79,25 @@ type GetHuggingFaceModelsResponse =
type GetByAttrsArg = operations['get_model_records_by_attrs']['parameters']['query'];
+// Orphaned models types - manually defined since the schema hasn't been regenerated yet
+type OrphanedModelInfo = {
+ path: string;
+ absolute_path: string;
+ files: string[];
+ size_bytes: number;
+};
+
+type GetOrphanedModelsResponse = OrphanedModelInfo[];
+
+type DeleteOrphanedModelsArg = {
+ paths: string[];
+};
+
+type DeleteOrphanedModelsResponse = {
+ deleted: string[];
+ errors: Record;
+};
+
const modelConfigsAdapter = createEntityAdapter({
selectId: (entity) => entity.key,
sortComparer: (a, b) => a.name.localeCompare(b.name),
@@ -351,6 +370,19 @@ export const modelsApi = api.injectEndpoints({
}
},
}),
+ getOrphanedModels: build.query({
+ query: () => ({
+ url: buildModelsUrl('sync/orphaned'),
+ method: 'GET',
+ }),
+ }),
+ deleteOrphanedModels: build.mutation({
+ query: (arg) => ({
+ url: buildModelsUrl('sync/orphaned'),
+ method: 'DELETE',
+ body: arg,
+ }),
+ }),
}),
});
@@ -375,6 +407,8 @@ export const {
useResetHFTokenMutation,
useEmptyModelCacheMutation,
useReidentifyModelMutation,
+ useGetOrphanedModelsQuery,
+ useDeleteOrphanedModelsMutation,
} = modelsApi;
export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select();
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 98e43bcb722..ebab3364887 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -403,6 +403,43 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/api/v2/models/sync/orphaned": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Orphaned Models
+ * @description Find orphaned model directories.
+ *
+ * Orphaned models are directories in the models folder that contain model files
+ * but are not referenced in the database. This can happen when models are deleted
+ * from the database but the files remain on disk.
+ *
+ * Returns:
+ * List of orphaned model directory information
+ */
+ get: operations["get_orphaned_models"];
+ put?: never;
+ post?: never;
+ /**
+ * Delete Orphaned Models
+ * @description Delete specified orphaned model directories.
+ *
+ * Args:
+ * request: Request containing list of relative paths to delete
+ *
+ * Returns:
+ * Response indicating which paths were deleted and which had errors
+ */
+ delete: operations["delete_orphaned_models"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/download_queue/": {
parameters: {
query?: never;
@@ -6419,6 +6456,35 @@ export type components = {
*/
deleted_images: string[];
};
+ /**
+ * DeleteOrphanedModelsRequest
+ * @description Request to delete specific orphaned model directories.
+ */
+ DeleteOrphanedModelsRequest: {
+ /**
+ * Paths
+ * @description List of relative paths to delete
+ */
+ paths: string[];
+ };
+ /**
+ * DeleteOrphanedModelsResponse
+ * @description Response from deleting orphaned models.
+ */
+ DeleteOrphanedModelsResponse: {
+ /**
+ * Deleted
+ * @description Paths that were successfully deleted
+ */
+ deleted: string[];
+ /**
+ * Errors
+ * @description Paths that had errors, with error messages
+ */
+ errors: {
+ [key: string]: string;
+ };
+ };
/**
* Denoise - SD1.5, SDXL
* @description Denoises noisy latents to decodable images
@@ -20398,6 +20464,32 @@ export type components = {
*/
items: components["schemas"]["ImageDTO"][];
};
+ /**
+ * OrphanedModelInfo
+ * @description Information about an orphaned model directory.
+ */
+ OrphanedModelInfo: {
+ /**
+ * Path
+ * @description Relative path to the orphaned directory from models root
+ */
+ path: string;
+ /**
+ * Absolute Path
+ * @description Absolute path to the orphaned directory
+ */
+ absolute_path: string;
+ /**
+ * Files
+ * @description List of model files in this directory
+ */
+ files: string[];
+ /**
+ * Size Bytes
+ * @description Total size of all files in bytes
+ */
+ size_bytes: number;
+ };
/**
* OutputFieldJSONSchemaExtra
* @description Extra attributes to be added to input fields and their OpenAPI schema. Used by the workflow editor
@@ -28160,6 +28252,59 @@ export interface operations {
};
};
};
+ get_orphaned_models: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OrphanedModelInfo"][];
+ };
+ };
+ };
+ };
+ delete_orphaned_models: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["DeleteOrphanedModelsRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DeleteOrphanedModelsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
list_downloads: {
parameters: {
query?: never;
From f4e4ccc7393760c1bb2d90d242010cfc408c14bd Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sat, 31 Jan 2026 23:46:07 -0500
Subject: [PATCH 3/6] chore(CI/CD): bump version to 6.11.0.post1 (#8818)
---
invokeai/version/invokeai_version.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py
index 67340893b60..81ff40b405f 100644
--- a/invokeai/version/invokeai_version.py
+++ b/invokeai/version/invokeai_version.py
@@ -1 +1 @@
-__version__ = "6.11.0.rc1"
+__version__ = "6.11.0.post1"
From bfe2aa0f56038c68e28bb00f7abe6da686ca1cfd Mon Sep 17 00:00:00 2001
From: Alexander Eichhorn
Date: Sun, 1 Feb 2026 05:51:33 +0100
Subject: [PATCH 4/6] feat(model_manager): add missing models filter to Model
Manager (#8801)
* feat(model_manager): add missing models filter to Model Manager
Adds the ability to view and manage orphaned model database entries
where the underlying files have been deleted externally.
Changes:
- Add GET /v2/models/missing API endpoint to list models with missing files
- Add "Missing Files" filter option to Model Manager type filter dropdown
- Display "Missing Files" badge on models with missing files in the list
- Automatically exclude missing models from model selection dropdowns
to prevent users from selecting unavailable models for generation
* fix(ui): enable Select All checkbox for missing models filter
The Select All checkbox was disabled when the missing models filter was
active because the bulk actions component didn't use the missing models
query data. Now it correctly uses useGetMissingModelsQuery when the
filter is set to 'missing'.
* test(model_manager): add tests for missing model detection and bulk delete
Tests _scan_for_missing_models and the unregister/delete workflow for
models whose files have been removed externally.
* Chore Ruff check
---
invokeai/app/api/routers/model_manager.py | 22 ++
invokeai/frontend/web/public/locales/en.json | 2 +
.../web/src/features/modelManagerV2/models.ts | 6 +-
.../store/modelManagerV2Slice.ts | 5 +-
.../MissingModelsContext.tsx | 32 +++
.../subpanels/ModelManagerPanel/ModelList.tsx | 27 ++-
.../ModelListBulkActions.tsx | 25 +-
.../ModelManagerPanel/ModelListItem.tsx | 13 +-
.../ModelManagerPanel/ModelTypeFilter.tsx | 35 ++-
.../web/src/services/api/endpoints/models.ts | 9 +
.../src/services/api/hooks/modelsByType.ts | 24 +-
.../frontend/web/src/services/api/schema.ts | 43 ++++
.../model_install/test_missing_models.py | 220 ++++++++++++++++++
13 files changed, 447 insertions(+), 16 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx
create mode 100644 tests/app/services/model_install/test_missing_models.py
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index 3a8a517fa0c..f67393f2ea2 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -149,6 +149,28 @@ async def list_model_records(
return ModelsList(models=found_models)
+@model_manager_router.get(
+ "/missing",
+ operation_id="list_missing_models",
+ responses={200: {"description": "List of models with missing files"}},
+)
+async def list_missing_models() -> ModelsList:
+ """Get models whose files are missing from disk.
+
+ These are models that have database entries but their corresponding
+ weight files have been deleted externally (not via Model Manager).
+ """
+ record_store = ApiDependencies.invoker.services.model_manager.store
+ models_path = ApiDependencies.invoker.services.configuration.models_path
+
+ missing_models: list[AnyModelConfig] = []
+ for model_config in record_store.all_models():
+ if not (models_path / model_config.path).resolve().exists():
+ missing_models.append(model_config)
+
+ return ModelsList(models=missing_models)
+
+
@model_manager_router.get(
"/get_by_attrs",
operation_id="get_model_records_by_attrs",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index f350dbde3af..3c94bbfd6e1 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -974,6 +974,8 @@
"loraModels": "LoRAs",
"main": "Main",
"metadata": "Metadata",
+ "missingFiles": "Missing Files",
+ "missingFilesTooltip": "Model files are missing from disk",
"model": "Model",
"modelConversionFailed": "Model Conversion Failed",
"modelConverted": "Model Converted",
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/models.ts b/invokeai/frontend/web/src/features/modelManagerV2/models.ts
index cd83315d48c..c4dd56f8113 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/models.ts
+++ b/invokeai/frontend/web/src/features/modelManagerV2/models.ts
@@ -22,15 +22,15 @@ import {
} from 'services/api/types';
import { objectEntries } from 'tsafe';
-import type { FilterableModelType } from './store/modelManagerV2Slice';
+import type { ModelCategoryType } from './store/modelManagerV2Slice';
export type ModelCategoryData = {
- category: FilterableModelType;
+ category: ModelCategoryType;
i18nKey: string;
filter: (config: AnyModelConfig) => boolean;
};
-export const MODEL_CATEGORIES: Record = {
+export const MODEL_CATEGORIES: Record = {
unknown: {
category: 'unknown',
i18nKey: 'common.unknown',
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts
index 65c9cbc1302..092998d0c31 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts
+++ b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts
@@ -7,7 +7,10 @@ import { zModelType } from 'features/nodes/types/common';
import { assert } from 'tsafe';
import z from 'zod';
-const zFilterableModelType = zModelType.exclude(['onnx']).or(z.literal('refiner'));
+const zModelCategoryType = zModelType.exclude(['onnx']).or(z.literal('refiner'));
+export type ModelCategoryType = z.infer;
+
+const zFilterableModelType = zModelCategoryType.or(z.literal('missing'));
export type FilterableModelType = z.infer;
const zModelManagerState = z.object({
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx
new file mode 100644
index 00000000000..2490a5a8648
--- /dev/null
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/MissingModelsContext.tsx
@@ -0,0 +1,32 @@
+import type { PropsWithChildren } from 'react';
+import { createContext, useContext, useMemo } from 'react';
+import { modelConfigsAdapterSelectors, useGetMissingModelsQuery } from 'services/api/endpoints/models';
+
+type MissingModelsContextValue = {
+ missingModelKeys: Set;
+ isLoading: boolean;
+};
+
+const MissingModelsContext = createContext({
+ missingModelKeys: new Set(),
+ isLoading: false,
+});
+
+export const MissingModelsProvider = ({ children }: PropsWithChildren) => {
+ const { data, isLoading } = useGetMissingModelsQuery();
+
+ const value = useMemo(() => {
+ const missingModels = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
+ const missingModelKeys = new Set(missingModels.map((m) => m.key));
+ return { missingModelKeys, isLoading };
+ }, [data, isLoading]);
+
+ return {children};
+};
+
+const useMissingModels = () => useContext(MissingModelsContext);
+
+export const useIsModelMissing = (modelKey: string) => {
+ const { missingModelKeys } = useMissingModels();
+ return missingModelKeys.has(modelKey);
+};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
index 2159d538bee..f3be0b4686c 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
@@ -18,12 +18,14 @@ import { serializeError } from 'serialize-error';
import {
modelConfigsAdapterSelectors,
useBulkDeleteModelsMutation,
+ useGetMissingModelsQuery,
useGetModelConfigsQuery,
} from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';
import { BulkDeleteModelsModal } from './BulkDeleteModelsModal';
import { FetchingModelsLoader } from './FetchingModelsLoader';
+import { MissingModelsProvider } from './MissingModelsContext';
import { ModelListWrapper } from './ModelListWrapper';
const log = logger('models');
@@ -40,11 +42,30 @@ const ModelList = () => {
const { isOpen, close } = useBulkDeleteModal();
const [isDeleting, setIsDeleting] = useState(false);
- const { data, isLoading } = useGetModelConfigsQuery();
+ const { data: allModelsData, isLoading: isLoadingAll } = useGetModelConfigsQuery();
+ const { data: missingModelsData, isLoading: isLoadingMissing } = useGetMissingModelsQuery();
const [bulkDeleteModels] = useBulkDeleteModelsMutation();
+ const data = filteredModelType === 'missing' ? missingModelsData : allModelsData;
+ const isLoading = filteredModelType === 'missing' ? isLoadingMissing : isLoadingAll;
+
const models = useMemo(() => {
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
+
+ // For missing models filter, show all models in a single category
+ if (filteredModelType === 'missing') {
+ const filtered = modelConfigs.filter(
+ (m) =>
+ m.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ m.base.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ m.type.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ return {
+ total: filtered.length,
+ byCategory: [{ i18nKey: 'modelManager.missingFiles', configs: filtered }],
+ };
+ }
+
const baseFilteredModelConfigs = modelsFilter(modelConfigs, searchTerm, filteredModelType);
const byCategory: { i18nKey: string; configs: AnyModelConfig[] }[] = [];
const total = baseFilteredModelConfigs.length;
@@ -128,7 +149,7 @@ const ModelList = () => {
}, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]);
return (
- <>
+
@@ -152,7 +173,7 @@ const ModelList = () => {
modelCount={selectedModelKeys.length}
isDeleting={isDeleting}
/>
- >
+
);
};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx
index 2442bd02162..1e6281f1c17 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx
@@ -11,7 +11,11 @@ import {
import { t } from 'i18next';
import { memo, useCallback, useMemo } from 'react';
import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi';
-import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
+import {
+ modelConfigsAdapterSelectors,
+ useGetMissingModelsQuery,
+ useGetModelConfigsQuery,
+} from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';
import { useBulkDeleteModal } from './ModelList';
@@ -31,7 +35,8 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) =>
const filteredModelType = useAppSelector(selectFilteredModelType);
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
const searchTerm = useAppSelector(selectSearchTerm);
- const { data } = useGetModelConfigsQuery();
+ const { data: allModelsData } = useGetModelConfigsQuery();
+ const { data: missingModelsData } = useGetMissingModelsQuery();
const bulkDeleteModal = useBulkDeleteModal();
const handleBulkDelete = useCallback(() => {
@@ -40,10 +45,24 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) =>
// Calculate displayed (filtered) model keys
const displayedModelKeys = useMemo(() => {
+ // Use missing models data when the filter is 'missing'
+ const data = filteredModelType === 'missing' ? missingModelsData : allModelsData;
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
+
+ // For missing models filter, only apply search term filter
+ if (filteredModelType === 'missing') {
+ const filtered = modelConfigs.filter(
+ (m) =>
+ m.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ m.base.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ m.type.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ return filtered.map((m) => m.key);
+ }
+
const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType);
return filteredModels.map((m) => m.key);
- }, [data, searchTerm, filteredModelType]);
+ }, [allModelsData, missingModelsData, searchTerm, filteredModelType]);
const { allSelected, someSelected } = useMemo(() => {
if (displayedModelKeys.length === 0) {
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
index 5719752ff01..9547046ba41 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { chakra, Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library';
+import { Badge, chakra, Checkbox, Flex, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
@@ -15,8 +15,10 @@ import { filesize } from 'filesize';
import type { ChangeEvent, MouseEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
+import { PiWarningBold } from 'react-icons/pi';
import type { AnyModelConfig } from 'services/api/types';
+import { useIsModelMissing } from './MissingModelsContext';
import ModelImage from './ModelImage';
const StyledLabel = chakra('label');
@@ -58,6 +60,7 @@ const sx: SystemStyleObject = {
const ModelListItem = ({ model }: ModelListItemProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const isMissing = useIsModelMissing(model.key);
const selectIsSelected = useMemo(
() =>
createSelector(
@@ -139,6 +142,14 @@ const ModelListItem = ({ model }: ModelListItemProps) => {
+ {isMissing && (
+
+
+
+ {t('modelManager.missingFiles')}
+
+
+ )}
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
index dcb22071482..5aa8e628869 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
@@ -1,11 +1,16 @@
-import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
+import { Button, Flex, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { ModelCategoryData } from 'features/modelManagerV2/models';
import { MODEL_CATEGORIES, MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models';
+import type { ModelCategoryType } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { selectFilteredModelType, setFilteredModelType } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-import { PiFunnelBold } from 'react-icons/pi';
+import { PiFunnelBold, PiWarningBold } from 'react-icons/pi';
+
+const isModelCategoryType = (type: string): type is ModelCategoryType => {
+ return type in MODEL_CATEGORIES;
+};
export const ModelTypeFilter = memo(() => {
const { t } = useTranslation();
@@ -16,13 +21,37 @@ export const ModelTypeFilter = memo(() => {
dispatch(setFilteredModelType(null));
}, [dispatch]);
+ const setMissingFilter = useCallback(() => {
+ dispatch(setFilteredModelType('missing'));
+ }, [dispatch]);
+
+ const getButtonLabel = () => {
+ if (filteredModelType === 'missing') {
+ return t('modelManager.missingFiles');
+ }
+ if (filteredModelType && isModelCategoryType(filteredModelType)) {
+ return t(MODEL_CATEGORIES[filteredModelType].i18nKey);
+ }
+ return t('modelManager.allModels');
+ };
+
return (