from __future__ import annotations import importlib import importlib.util import json import logging import pkgutil from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable REQUIRED_INFO_FIELDS = {"name", "version", "category", "description"} SUPPORTED_SETTING_TYPES = { "int", "float", "bool", "choice", "text", "multiline_text", "path", "folder", "slider", } class ModelPluginError(RuntimeError): pass @dataclass(frozen=True, slots=True) class ModelPlugin: id: str info: dict[str, Any] training_settings: dict[str, dict[str, Any]] = field(default_factory=dict) generation_settings: dict[str, dict[str, Any]] = field(default_factory=dict) training_tool: dict[str, Any] = field(default_factory=dict) generation_tool: dict[str, Any] = field(default_factory=dict) module_name: str = "" plugin_path: Path | None = None @property def name(self) -> str: return str(self.info.get("name", self.id)) @property def trainer_id(self) -> str: return str(self.training_tool.get("id") or f"{self.id}_trainer") @property def generator_id(self) -> str: return str(self.generation_tool.get("id") or f"{self.id}_generator") class ModelPluginRegistry: """Discovers model plugins and validates their setting schemas.""" def __init__(self, root: Path, logger: logging.Logger | None = None) -> None: self.root = root.resolve() self.logger = logger or logging.getLogger(__name__) self.plugins: dict[str, ModelPlugin] = {} self.errors: list[str] = [] self.discover() def discover(self) -> None: self.plugins = {} self.errors = [] for module_name in self._candidate_modules(): try: plugin = self._load_module_plugin(module_name) except Exception as exc: message = f"{module_name}: {exc}" self.errors.append(message) self.logger.warning("Model plugin failed to load: %s", message) continue if plugin.id in self.plugins: self.errors.append(f"{module_name}: duplicate model plugin id {plugin.id}") continue self.plugins[plugin.id] = plugin def _candidate_modules(self) -> list[str | Path]: modules: list[str | Path] = [] try: package = importlib.import_module("adam.model_plugins_builtin") for item in pkgutil.iter_modules(package.__path__, package.__name__ + "."): if not item.ispkg: continue modules.append(item.name + ".manifest") except Exception as exc: self.errors.append(f"adam.model_plugins_builtin: {exc}") models_dir = self.root / "models" if models_dir.is_dir(): for folder in sorted(models_dir.iterdir()): manifest = folder / "manifest.py" if not folder.is_dir() or not manifest.is_file(): continue modules.append(manifest) return modules def _load_module_plugin(self, module_name: str | Path) -> ModelPlugin: if isinstance(module_name, Path): fallback_id = module_name.parent.name unique_name = f"adam_user_model_{fallback_id}_{abs(hash(str(module_name.resolve())))}" spec = importlib.util.spec_from_file_location(unique_name, module_name) if spec is None or spec.loader is None: raise ModelPluginError(f"Could not load manifest file: {module_name}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module_label = str(module_name) else: module = importlib.import_module(module_name) fallback_id = module_name.split(".")[-2] module_label = module_name plugin_id = str(getattr(module, "PLUGIN_ID", "") or fallback_id) info = dict(getattr(module, "MODEL_INFO", {})) missing = REQUIRED_INFO_FIELDS - set(info) if missing: raise ModelPluginError( "MODEL_INFO is missing " + ", ".join(sorted(missing)) ) training_settings = self._validate_schema( dict(getattr(module, "TRAINING_SETTINGS", {})), f"{plugin_id} training", ) generation_settings = self._validate_schema( dict(getattr(module, "GENERATION_SETTINGS", {})), f"{plugin_id} generation", ) plugin_path = Path(getattr(module, "__file__", "")).resolve().parent return ModelPlugin( id=plugin_id, info=info, training_settings=training_settings, generation_settings=generation_settings, training_tool=dict(getattr(module, "TRAINING_TOOL", {})), generation_tool=dict(getattr(module, "GENERATION_TOOL", {})), module_name=module_label, plugin_path=plugin_path, ) @staticmethod def _validate_schema( schema: dict[str, Any], label: str, ) -> dict[str, dict[str, Any]]: clean: dict[str, dict[str, Any]] = {} for key, raw in schema.items(): if not isinstance(raw, dict): raise ModelPluginError(f"{label} setting {key} must be an object") spec = dict(raw) setting_type = str(spec.get("type", "text")) if setting_type not in SUPPORTED_SETTING_TYPES: raise ModelPluginError( f"{label} setting {key} has unsupported type {setting_type}" ) spec["type"] = setting_type spec.setdefault("label", key.replace("_", " ").title()) spec.setdefault("group", "Basic") if setting_type == "choice": options = spec.get("options", []) if not isinstance(options, (list, tuple)) or not options: raise ModelPluginError(f"{label} setting {key} needs options") spec["options"] = list(options) spec.setdefault("default", spec["options"][0]) clean[str(key)] = spec return clean def get(self, plugin_id: str) -> ModelPlugin: return self.plugins[plugin_id] def all(self) -> list[ModelPlugin]: return list(self.plugins.values()) def by_trainer(self, trainer: str) -> ModelPlugin | None: return next((plugin for plugin in self.plugins.values() if plugin.id == trainer), None) def training_schema(self, trainer: str) -> dict[str, dict[str, Any]]: plugin = self.by_trainer(trainer) return plugin.training_settings if plugin else {} def generation_schema_for_tool(self, tool_id: str) -> dict[str, dict[str, Any]]: for plugin in self.plugins.values(): if plugin.generator_id == tool_id: return plugin.generation_settings return {} def training_tool_specs(self) -> list[dict[str, Any]]: return [ self._tool_spec(plugin, mode="training") for plugin in self.plugins.values() if plugin.training_tool ] def generation_tool_specs(self) -> list[dict[str, Any]]: return [ self._tool_spec(plugin, mode="generation") for plugin in self.plugins.values() if plugin.generation_tool ] @staticmethod def _tool_spec(plugin: ModelPlugin, *, mode: str) -> dict[str, Any]: tool = dict(plugin.training_tool if mode == "training" else plugin.generation_tool) schema = plugin.training_settings if mode == "training" else plugin.generation_settings core_arguments = ( ["dataset_dir", "model_name", "epochs", "output_dir", "resume_from"] if mode == "training" else [ "model_name", "model_path", "prompt", "image_count", "steps", "seed", "sampler", "aspect_ratio", ] ) core_required = ( ["dataset_dir", "model_name", "epochs", "output_dir"] if mode == "training" else ["model_name", "model_path", "image_count", "steps", "seed"] ) defaults = { "id": plugin.trainer_id if mode == "training" else plugin.generator_id, "name": f"{plugin.name} {'Trainer' if mode == 'training' else 'Generator'}", "description": plugin.info.get("description", ""), "category": "Training" if mode == "training" else "Output", "entry_function": "train" if mode == "training" else "generate", "arguments": [*core_arguments, *list(schema)], "required_arguments": [ *core_required, *[key for key, spec in schema.items() if bool(spec.get("required"))], ], "capabilities": ( ["fresh_training", "progress", "pause", "cancel"] if mode == "training" else ["image_generation", "progress", "cancel"] ), "requires_confirmation": mode == "training", "enabled": True, "demo": False, } defaults.update(tool) defaults["arguments"] = list(defaults.get("arguments") or [*core_arguments, *list(schema)]) defaults["required_arguments"] = list(defaults.get("required_arguments") or []) return defaults def validate_settings( self, trainer: str, values: dict[str, Any], *, mode: str = "training", ) -> list[str]: plugin = self.by_trainer(trainer) if not plugin: return [f"Unknown model plugin: {trainer}"] schema = plugin.training_settings if mode == "training" else plugin.generation_settings return validate_settings(schema, values) def validate_settings(schema: dict[str, dict[str, Any]], values: dict[str, Any]) -> list[str]: errors: list[str] = [] for key, spec in schema.items(): value = values.get(key, spec.get("default")) label = str(spec.get("label", key)) if spec.get("required") and (value is None or str(value).strip() == ""): errors.append(f"{label} is required.") continue if value in (None, "") and not spec.get("required"): continue setting_type = str(spec.get("type", "text")) try: if setting_type in {"int", "slider"}: if isinstance(value, bool): raise ValueError numeric = int(value) elif setting_type == "float": if isinstance(value, bool): raise ValueError numeric = float(value) else: numeric = None except (TypeError, ValueError): errors.append(f"{label} must be a number.") continue if numeric is not None: if "min" in spec and numeric < float(spec["min"]): errors.append(f"{label} must be at least {spec['min']}.") if "max" in spec and numeric > float(spec["max"]): errors.append(f"{label} must be at most {spec['max']}.") if setting_type == "choice" and "options" in spec and value not in spec["options"]: errors.append(f"{label} must be one of: {', '.join(map(str, spec['options']))}.") if setting_type == "path" and spec.get("must_exist") and not Path(str(value)).expanduser().is_file(): errors.append(f"{label} must point to an existing file.") if setting_type == "folder" and spec.get("must_exist") and not Path(str(value)).expanduser().is_dir(): errors.append(f"{label} must point to an existing folder.") return errors def load_presets(root: Path, plugin_id: str, mode: str) -> dict[str, dict[str, Any]]: path = root.resolve() / "config" / "model_presets.json" try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} presets = payload.get(plugin_id, {}).get(mode, {}) return dict(presets) if isinstance(presets, dict) else {} def save_preset( root: Path, plugin_id: str, mode: str, name: str, settings: dict[str, Any], ) -> None: path = root.resolve() / "config" / "model_presets.json" try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): payload = {} payload.setdefault(plugin_id, {}).setdefault(mode, {})[name] = settings temporary = path.with_suffix(".tmp") temporary.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") temporary.replace(path) def plugin_function(plugin: ModelPlugin, function_name: str) -> Callable[..., Any] | None: if plugin.module_name.endswith("manifest.py"): spec = importlib.util.spec_from_file_location( f"adam_user_model_{plugin.id}_{abs(hash(plugin.module_name))}", plugin.module_name, ) if spec is None or spec.loader is None: return None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) else: module = importlib.import_module(plugin.module_name) function = getattr(module, function_name, None) return function if callable(function) else None def safe_plugin_id(name: str) -> str: cleaned = "".join( character.lower() if character.isalnum() else "_" for character in name.strip() ) cleaned = "_".join(part for part in cleaned.split("_") if part) return cleaned[:48] or "my_model" def scaffold_model_plugin( root: Path, *, plugin_id: str, name: str, architecture: str = "custom", output_type: str = "image", include_training: bool = True, include_generation: bool = True, ) -> Path: """Create a simple user-editable model plugin folder.""" plugin_id = safe_plugin_id(plugin_id) if plugin_id in {"ddpm", "flow", "lora", "model_template"}: raise ModelPluginError("Choose a plugin id that does not conflict with a built-in model.") folder = root.resolve() / "models" / plugin_id if folder.exists(): raise ModelPluginError(f"A model plugin folder already exists: {folder}") folder.mkdir(parents=True) (folder / "__init__.py").write_text( f'"""ADAM model plugin: {name}."""\n', encoding="utf-8", ) (folder / "manifest.py").write_text( _manifest_template( plugin_id=plugin_id, name=name, architecture=architecture, output_type=output_type, include_training=include_training, include_generation=include_generation, ), encoding="utf-8", ) (folder / "model.py").write_text(_model_template(), encoding="utf-8") if include_training: (folder / "trainer.py").write_text(_trainer_template(), encoding="utf-8") if include_generation: (folder / "generator.py").write_text(_generator_template(), encoding="utf-8") return folder def _manifest_template( *, plugin_id: str, name: str, architecture: str, output_type: str, include_training: bool, include_generation: bool, ) -> str: plugin_id_json = json.dumps(plugin_id) name_json = json.dumps(name) architecture_json = json.dumps(architecture) output_type_json = json.dumps(output_type) training_tool = ( "{\n" f' "id": "{plugin_id}_trainer",\n' f' "name": {json.dumps(name + " Trainer")},\n' f' "backend": {{"type": "python", "module": "models.{plugin_id}.trainer", "function": "train"}},\n' "}" if include_training else "{}" ) generation_tool = ( "{\n" f' "id": "{plugin_id}_generator",\n' f' "name": {json.dumps(name + " Generator")},\n' f' "model_trainers": ["{plugin_id}"],\n' f' "backend": {{"type": "python", "module": "models.{plugin_id}.generator", "function": "generate"}},\n' "}" if include_generation else "{}" ) return f'''PLUGIN_ID = {plugin_id_json} MODEL_INFO = {{ "name": {name_json}, "version": "0.1", "category": "Image Generation", "description": {json.dumps("Describe what " + name + " trains or generates.")}, "architecture": {architecture_json}, "status": "experimental", "output_type": {output_type_json}, }} TRAINING_SETTINGS = {{ "resolution": {{"label": "Resolution", "type": "choice", "options": [64, 128, 256, 384, 512], "default": 256, "group": "Basic"}}, "batch_size": {{"label": "Batch size", "type": "int", "default": 1, "min": 1, "max": 64, "group": "Basic"}}, "learning_rate": {{"label": "Learning rate", "type": "float", "default": 0.0001, "min": 0.0000001, "max": 0.1, "decimals": 7, "group": "Optimization"}}, "mixed_precision": {{"label": "Precision", "type": "choice", "options": ["fp16", "no"], "default": "fp16", "group": "Optimization"}}, "preview_enabled": {{"label": "Generate previews while training", "type": "bool", "default": True, "group": "Preview"}}, "preview_every": {{"label": "Preview interval", "type": "int", "default": 5, "min": 1, "max": 100000, "group": "Preview"}}, "preview_prompt": {{"label": "Preview prompt", "type": "text", "default": "", "group": "Preview"}}, "preview_seed": {{"label": "Preview seed", "type": "int", "default": 123456789, "min": 0, "max": 2147483647, "group": "Preview"}}, }} GENERATION_SETTINGS = {{ "prompt": {{"label": "Prompt", "type": "multiline_text", "default": "", "group": "Prompt"}}, "image_count": {{"label": "Images", "type": "int", "default": 1, "min": 1, "max": 48, "group": "Generation"}}, "steps": {{"label": "Steps", "type": "int", "default": 30, "min": 1, "max": 500, "group": "Generation"}}, "seed": {{"label": "Seed", "type": "int", "default": 0, "min": 0, "max": 2147483647, "group": "Generation"}}, }} TRAINING_TOOL = {training_tool} GENERATION_TOOL = {generation_tool} ''' def _model_template() -> str: return '''from __future__ import annotations from typing import Any def load_model(model_path: str, settings: dict[str, Any] | None = None) -> Any: """Load your model or inference pipeline here.""" raise NotImplementedError("Add your model loading code.") ''' def _trainer_template() -> str: return '''from __future__ import annotations from typing import Any def train(context, **settings: Any) -> dict[str, Any]: """Train the model and report progress back to ADAM.""" context.log("Replace this with real training code.") context.progress(100, "Training placeholder complete") return {} ''' def _generator_template() -> str: return '''from __future__ import annotations from typing import Any def generate(context, **settings: Any) -> dict[str, Any]: """Generate outputs and report progress back to ADAM.""" context.log("Replace this with real generation code.") context.progress(100, "Generation placeholder complete") return {} '''