from __future__ import annotations from pathlib import Path import os import tempfile import threading import time from typing import Any from urllib.parse import parse_qs from adam.assets import Asset, AssetRegistry from adam.commands import CommandValidationError, TrainingCommand from adam.dataset_registry import DatasetRecord, DatasetRegistry from adam.dataset_lab import scan_dataset from adam.generations import build_generation_plan, generation_tools from adam.remote_api import ( RemoteApiError, RemoteResponse, bounded_float, bounded_int, bounded_text, coerce_json_object, error_response, json_response, media_response, parse_pagination, sanitized_arguments, ) from adam.remote_dispatcher import RemoteCommandDispatcher from adam.remote_media import OpaqueIdCodec, RemoteMediaStore from adam.studio import caption_path, image_files, StudioStore from adam.training_assistant import append_preflight_summary class RemoteV1Service: """Versioned ADAM Remote API built around existing ADAM services.""" def __init__( self, *, root: Path, config: Any, jobs: Any, planner: Any, dispatcher: RemoteCommandDispatcher, codec: OpaqueIdCodec, media: RemoteMediaStore, auto_approve_training: Any, ) -> None: self.root = root.resolve() self.config = config self.jobs = jobs self.planner = planner self.dispatcher = dispatcher self.codec = codec self.media = media self.auto_approve_training = auto_approve_training self._asset_fallback: AssetRegistry | None = None self._studio: StudioStore | None = None # The dashboard requests both schemas at once. They share an asset # registry, so build one short-lived catalog instead of asking two # request threads to rediscover and rewrite it concurrently. self._model_catalog_lock = threading.RLock() self._model_catalog: list[dict[str, Any]] = [] self._model_catalog_at = 0.0 def route( self, method: str, path: str, query: str = "", payload: dict[str, Any] | None = None, ) -> RemoteResponse | None: if not path.startswith("/api/v1/"): return None parts = [part for part in path.removeprefix("/api/v1/").split("/") if part] parsed_query = parse_qs(query) try: if method == "GET": return self._get(parts, parsed_query) if method == "POST": return self._post(parts, coerce_json_object(payload or {})) except RemoteApiError as exc: return error_response(str(exc), status=exc.status) except (CommandValidationError, ValueError) as exc: return error_response(str(exc), status=400) except Exception as exc: return error_response(f"ADAM could not finish that remote request: {exc}", status=500) return error_response("Unsupported Remote API method.", status=405) def _get(self, parts: list[str], query: dict[str, list[str]]) -> RemoteResponse: if parts == ["datasets"]: return json_response({"ok": True, "datasets": self.datasets()}) if parts == ["datasets", "locations"]: return json_response({"ok": True, "locations": self.dataset_locations()}) if parts == ["training", "presets"]: return json_response({"ok": True, "presets": self.training_presets()}) if len(parts) == 2 and parts[0] == "datasets": return json_response({"ok": True, "dataset": self.dataset_detail(parts[1])}) if len(parts) == 3 and parts[0] == "datasets" and parts[2] == "thumbnail": return self.dataset_thumbnail(parts[1], query) if len(parts) == 3 and parts[0] == "datasets" and parts[2] == "items": return json_response({"ok": True, **self.dataset_items(parts[1], query)}) if len(parts) == 2 and parts[0] == "media": return self.media_file(parts[1], query) if parts == ["models"]: return json_response({"ok": True, "models": self.models()}) if parts == ["training", "schema"]: return json_response({"ok": True, **self.training_schema()}) if parts == ["generation", "schema"]: return json_response({"ok": True, **self.generation_schema()}) if len(parts) == 2 and parts[0] == "jobs": return json_response({"ok": True, "job": self.job_detail(parts[1])}) raise RemoteApiError("Unknown Remote API endpoint.", status=404) def _post(self, parts: list[str], payload: dict[str, Any]) -> RemoteResponse: if len(parts) == 5 and parts[0] == "datasets" and parts[2] == "items" and parts[4] == "caption": return json_response({"ok": True, **self.update_caption(parts[1], parts[3], payload)}) if len(parts) == 5 and parts[0] == "datasets" and parts[2] == "items" and parts[4] == "decision": return json_response({"ok": True, **self.update_decision(parts[1], parts[3], payload)}) if len(parts) == 3 and parts[0] == "datasets" and parts[2] == "favorite": return json_response({"ok": True, **self.favorite_dataset(parts[1], payload)}) if len(parts) == 3 and parts[0] == "datasets" and parts[2] == "use": return json_response({"ok": True, "dataset": self.use_dataset(parts[1])}) if parts == ["training", "plan"]: return json_response({"ok": True, "plan": self.training_plan(payload)}) if parts == ["training", "start"]: return json_response({"ok": True, **self.start_training(payload)}) if parts == ["generation", "start"]: return json_response({"ok": True, **self.start_generation(payload)}) raise RemoteApiError("Unknown Remote API endpoint.", status=404) def _assets(self) -> AssetRegistry: assets = getattr(self.planner, "assets", None) if assets is None: if self._asset_fallback is None: self._asset_fallback = AssetRegistry(self.root) assets = self._asset_fallback if hasattr(assets, "discover"): assets.discover(self.config) return assets def _registry(self) -> Any: registry = getattr(self.planner, "registry", None) if registry is None: raise RemoteApiError("The ADAM tool registry is not available.", status=503) return registry def _studio_store(self) -> StudioStore: if self._studio is None: self._studio = StudioStore(self.root) return self._studio def _dataset_registry(self) -> DatasetRegistry: return DatasetRegistry(self.root, self.config) def _asset_public_id(self, asset: Asset) -> str: return self.codec.encode({"kind": asset.kind, "asset_id": asset.id}) def _asset_from_id(self, public_id: str, *, kind: str = "") -> Asset: payload = self.codec.decode(public_id) asset_id = str(payload.get("asset_id", "")) expected_kind = kind or str(payload.get("kind", "")) for asset in self._assets().assets: if asset.id == asset_id and (not expected_kind or asset.kind == expected_kind): return asset raise RemoteApiError("Unknown resource id.", status=404) def _item_path(self, item_id: str, *, dataset: Asset | None = None) -> tuple[Asset, Path, int]: payload = self.codec.decode(item_id) if payload.get("kind") != "dataset_image": raise RemoteApiError("Unknown media id.", status=404) asset_id = str(payload.get("asset_id", "")) index = bounded_int(payload.get("index"), minimum=0, maximum=100_000, default=0, label="Image index") if dataset is not None and dataset.id != asset_id: raise RemoteApiError("Dataset image does not belong to that dataset.", status=403) dataset_asset = dataset or next((item for item in self._assets().assets if item.id == asset_id and item.kind == "dataset"), None) if dataset_asset is None: raise RemoteApiError("Unknown dataset image.", status=404) paths = image_files(dataset_asset.path, limit=100_000) if index >= len(paths): raise RemoteApiError("Dataset image is no longer available.", status=404) path = paths[index].resolve() try: path.relative_to(Path(dataset_asset.path).expanduser().resolve()) except ValueError as exc: raise RemoteApiError("Dataset image is outside its dataset.", status=403) from exc return dataset_asset, path, index @staticmethod def _dataset_path(asset: Asset, path: Path) -> Path: resolved = path.expanduser().resolve() if not resolved.is_relative_to(Path(asset.path).expanduser().resolve()): raise RemoteApiError("File is outside its dataset.", status=403) return resolved def _dataset_counts(self, asset: Asset) -> dict[str, int]: review = self._studio_store().review(asset.path) images = image_files(asset.path, limit=100_000) resolved = {str(path.resolve()) for path in images} keep = sum(1 for path, decision in review.decisions.items() if decision == "keep" and path in resolved) reject = sum(1 for path, decision in review.decisions.items() if decision == "reject" and path in resolved) return { "accepted": keep, "rejected": reject, "unreviewed": max(0, len(images) - keep - reject), } def _dimensions(self, path: Path) -> dict[str, int]: try: from PIL import Image with Image.open(path) as image: return {"width": int(image.width), "height": int(image.height)} except Exception: return {"width": 0, "height": 0} def datasets(self) -> list[dict[str, Any]]: assets = self._assets() records = self._dataset_registry().discover(asset_registry=assets) by_path = {str(Path(asset.path).expanduser().resolve()): asset for asset in assets.assets if asset.kind == "dataset"} rows = [] for record in records[:300]: asset = by_path.get(str(Path(record.path).expanduser().resolve())) if asset is None and record.exists: asset = assets.register( kind="dataset", name=record.name, path=record.path, metadata={ "dataset_registry_source": record.source, "dataset_location_id": record.location_id, }, persist=False, ) if asset is None: continue rows.append(self._dataset_summary(asset, record)) if rows: assets.save() return rows def _dataset_summary(self, asset: Asset, record: DatasetRecord | None = None) -> dict[str, Any]: if record is None: record = self._dataset_registry().record_for_path(asset.path) exists = Path(asset.path).is_dir() public_id = self._asset_public_id(asset) if exists: review_state = self._studio_store().review(asset.path) accepted = sum(1 for decision in review_state.decisions.values() if decision == "keep") rejected = sum(1 for decision in review_state.decisions.values() if decision == "reject") review = { "accepted": accepted, "rejected": rejected, "unreviewed": max(0, record.image_count - accepted - rejected), } else: review = {"accepted": 0, "rejected": 0, "unreviewed": 0} warnings = list(record.warnings) if not exists and "Dataset folder is unavailable." not in warnings: warnings.append("Dataset folder is unavailable.") return { "id": public_id, "name": asset.name or record.name, "exists": exists, "available": exists, "source": record.source, "location_id": record.location_id, "favorite": record.favorite, "last_used_at": record.last_used_at, "item_count": record.item_count, "image_count": record.image_count, "video_count": record.video_count, "caption_count": record.caption_count, "missing_caption_count": record.missing_caption_count, "dataset_format": record.dataset_format, "warnings": warnings[:6], "thumbnail_url": f"/api/v1/datasets/{public_id}/thumbnail" if exists else "", "review": review, } def dataset_locations(self) -> list[dict[str, Any]]: registry = self._dataset_registry() return [ { "id": item.id, "name": item.name, "source": item.source, "exists": Path(item.path).is_dir(), "available": Path(item.path).is_dir(), "last_seen_at": item.last_seen_at, } for item in registry.known_locations() ][:200] def _first_image(self, folder: str | Path) -> Path | None: root = Path(folder).expanduser() if not root.is_dir(): return None try: for path in root.rglob("*"): if path.is_file() and path.suffix.casefold() in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}: return path except OSError: return None return None def dataset_detail(self, dataset_id: str) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") registry = self._dataset_registry() record = registry.record_for_path(asset.path) if not Path(asset.path).is_dir(): return self._dataset_summary(asset, record) report = scan_dataset(asset.path, limit=300) registry.refresh_async(asset.path, source=record.source, location_id=record.location_id) return { "id": self._asset_public_id(asset), "name": asset.name, "exists": True, "available": True, "source": record.source, "location_id": record.location_id, "favorite": record.favorite, "last_used_at": record.last_used_at, "image_count": report.image_count, "video_count": report.video_count, "item_count": report.image_count + report.video_count, "caption_count": report.caption_count, "missing_caption_count": report.missing_caption_count, "duplicate_groups": report.duplicate_groups, "dimensions": dict(list(report.dimensions.items())[:20]), "extensions": report.extensions, "warnings": report.warnings, "dataset_format": record.dataset_format, "thumbnail_url": f"/api/v1/datasets/{dataset_id}/thumbnail", "review": self._dataset_counts(asset), } def dataset_thumbnail(self, dataset_id: str, query: dict[str, list[str]]) -> RemoteResponse: asset = self._asset_from_id(dataset_id, kind="dataset") source = self._first_image(asset.path) if source is None: raise RemoteApiError("Dataset thumbnail is not available.", status=404) source = self._dataset_path(asset, source) size = bounded_int((query.get("size") or ["320"])[0], minimum=64, maximum=640, default=320, label="Media size") media = self.media.thumbnail(source, size=size) return media_response(media.path.read_bytes(), media.content_type, cache_seconds=86400) def dataset_items(self, dataset_id: str, query: dict[str, list[str]]) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") page = parse_pagination(query, default_size=24, max_size=60) paths = image_files(asset.path, limit=100_000) total = len(paths) review = self._studio_store().review(asset.path) rows = [] for index, path in enumerate(paths[page["offset"]: page["offset"] + page["limit"]], page["offset"]): path = self._dataset_path(asset, path) item_id = self.media.media_id(kind="dataset_image", asset_id=asset.id, index=index) caption_file = self._dataset_path(asset, caption_path(path)) try: caption = "" if caption_file.is_file(): with caption_file.open(encoding="utf-8") as handle: caption = handle.read(4000) except (OSError, UnicodeError): caption = "" rows.append({ "id": item_id, "display_name": path.name, "dimensions": self._dimensions(path), "caption": caption, "has_caption": bool(caption.strip()), "decision": review.decisions.get(str(path.resolve()), "unreviewed"), "thumbnail_url": f"/api/v1/media/{item_id}?size=320", "preview_url": f"/api/v1/media/{item_id}?size=960", }) return { "dataset": self.dataset_detail(dataset_id), "items": rows, "pagination": { "page": page["page"], "page_size": page["page_size"], "total": total, "has_next": page["offset"] + page["limit"] < total, }, } def media_file(self, media_id: str, query: dict[str, list[str]]) -> RemoteResponse: _dataset, source, _index = self._item_path(media_id) size = bounded_int((query.get("size") or ["320"])[0], minimum=64, maximum=1200, default=320, label="Media size") media = self.media.thumbnail(source, size=size) return media_response(media.path.read_bytes(), media.content_type, cache_seconds=86400) def update_caption(self, dataset_id: str, item_id: str, payload: dict[str, Any]) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") _asset, image, _index = self._item_path(item_id, dataset=asset) caption = bounded_text(payload.get("caption", ""), max_length=4000, label="Caption") target = self._dataset_path(asset, caption_path(image)) # Replace the file instead of writing through a possible hard link. temporary = None try: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as handle: temporary = Path(handle.name) handle.write(caption.rstrip() + ("\n" if caption else "")) os.replace(temporary, target) finally: if temporary is not None: temporary.unlink(missing_ok=True) return {"message": "Caption saved.", "item": {"id": item_id, "caption": caption, "has_caption": bool(caption)}} def update_decision(self, dataset_id: str, item_id: str, payload: dict[str, Any]) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") _asset, image, _index = self._item_path(item_id, dataset=asset) decision = bounded_text(payload.get("decision", "unreviewed"), max_length=32, label="Decision") if decision not in {"keep", "reject", "unreviewed"}: raise RemoteApiError("Decision must be keep, reject, or unreviewed.") self._studio_store().set_decision(asset.path, str(image), decision) return {"message": "Review decision saved.", "item": {"id": item_id, "decision": decision}} def favorite_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") enabled = bool(payload.get("favorite", True)) record = self._dataset_registry().favorite(asset.path, enabled) return { "message": "Dataset favorite updated.", "dataset": self._dataset_summary(asset, record), } def use_dataset(self, dataset_id: str) -> dict[str, Any]: asset = self._asset_from_id(dataset_id, kind="dataset") record = self._dataset_registry().touch(asset.path) return self._dataset_summary(asset, record) def models(self) -> list[dict[str, Any]]: with self._model_catalog_lock: if time.monotonic() - self._model_catalog_at < 1.0: return list(self._model_catalog) assets = self._assets() experiment_by_model: dict[str, Any] = {} try: for run in getattr(getattr(self.jobs, "experiments", None), "list_runs", lambda limit=100: [])(limit=100): experiment_by_model.setdefault(run.model_name, run) except Exception: experiment_by_model = {} rows = [] for asset in assets.assets: if asset.kind not in {"model", "base_model"} or not Path(asset.path).exists(): continue dataset = next((item for item in assets.assets if item.id == asset.dataset_id), None) metadata = dict(asset.metadata or {}) trigger_word = str(metadata.get("trigger_word") or (asset.name if asset.trainer == "lora" else "")) latest = experiment_by_model.get(asset.name) rows.append({ "id": self._asset_public_id(asset), "name": asset.name, "kind": asset.kind, "architecture": asset.trainer or ("stable_diffusion" if asset.kind == "base_model" else ""), "trainer": asset.trainer, "checkpoint_name": Path(asset.checkpoint or asset.path).name, "dataset": None if dataset is None else {"id": self._asset_public_id(dataset), "name": dataset.name}, "epochs": asset.epochs, "trigger_word": trigger_word, "latest_experiment": None if latest is None else { "id": latest.id, "status": latest.status, "resolution": latest.resolution, "dataset_name": latest.dataset_name, "trigger_word": getattr(latest, "trigger_word", ""), }, }) self._model_catalog = rows[:300] self._model_catalog_at = time.monotonic() return list(self._model_catalog) def training_schema(self) -> dict[str, Any]: registry = self._registry() trainers = [] for tool in registry.enabled(): if not tool.id.endswith("_trainer"): continue trainer = tool.id.removesuffix("_trainer") schema = registry.model_plugins.training_schema(trainer) trainers.append({ "id": trainer, "tool_id": tool.id, "name": tool.name, "requires_confirmation": tool.requires_confirmation, "settings": schema, "arguments": [arg for arg in tool.arguments if arg not in {"dataset_dir", "output_dir"}], "required_arguments": [arg for arg in tool.required_arguments if arg not in {"dataset_dir", "output_dir"}], }) return { "trainers": trainers, "datasets": self.datasets(), "base_models": [item for item in self.models() if item["kind"] == "base_model"], "presets": self.training_presets(), } def training_presets(self) -> list[dict[str, Any]]: presets = [ { "id": "standard_ddpm", "name": "Standard DDPM", "trainer": "ddpm", "epochs": 100, "settings": { "resolution": 128, "batch_size": 1, "learning_rate": 0.0001, "save_every": 10, "preview_enabled": True, "preview_every": 5, }, }, { "id": "quick_ddpm_test", "name": "Quick DDPM Test", "trainer": "ddpm", "epochs": 3, "settings": { "resolution": 64, "batch_size": 1, "learning_rate": 0.0001, "save_every": 1, "preview_enabled": True, "preview_every": 1, "training_intensity": 25, }, }, { "id": "oasis_training", "name": "Oasis Training", "trainer": "oasis", "epochs": 25, "settings": { "resolution": "256x144", "batch_size": 2, "learning_rate": 0.00002, "workers": 2, "preview_enabled": True, "preview_every": 5, }, }, ] for recipe in self._studio_store().recipes: presets.append({ "id": f"recipe_{recipe.id}", "name": recipe.name, "trainer": recipe.trainer, "epochs": recipe.epochs, "settings": { "preview_prompt": recipe.preview_prompt, **({"base_model": recipe.base_model} if recipe.base_model else {}), }, "user": True, }) available = {trainer["id"] for trainer in self.training_schema_no_presets()} return [preset for preset in presets if preset["trainer"] in available] def training_schema_no_presets(self) -> list[dict[str, Any]]: registry = self._registry() trainers = [] for tool in registry.enabled(): if not tool.id.endswith("_trainer"): continue trainer = tool.id.removesuffix("_trainer") trainers.append({"id": trainer}) return trainers def _build_training_plan(self, payload: dict[str, Any]) -> Any: if self.planner is None: raise RemoteApiError("Planning is not available in this ADAM session.", status=503) trainer = bounded_text(payload.get("trainer", "lora"), max_length=64, label="Trainer", required=True).casefold() dataset = self._asset_from_id(bounded_text(payload.get("dataset_id"), max_length=4000, label="Dataset", required=True), kind="dataset") if not Path(dataset.path).is_dir(): raise RemoteApiError("That dataset is unavailable on the PC.", status=404) model_name = bounded_text(payload.get("model_name") or dataset.name, max_length=96, label="Model name", required=True) epochs = bounded_int(payload.get("epochs"), minimum=1, maximum=100_000, default=10, label="Epochs") options = dict(payload.get("settings") or payload.get("training_options") or {}) if not isinstance(options, dict): raise RemoteApiError("Training settings must be an object.") trigger_word = bounded_text(payload.get("trigger_word") or options.get("trigger_word") or "", max_length=128, label="Trigger word") if trigger_word: options["trigger_word"] = trigger_word base_model = "" if trainer == "lora": base_id = bounded_text(payload.get("base_model_id", ""), max_length=4000, label="Base model") if base_id: base_model = self._asset_from_id(base_id, kind="base_model").path else: base_model = str(options.get("base_model") or "") if not base_model and hasattr(self.planner, "_lora_base_model"): base_model = str(self.planner._lora_base_model()) if base_model: options["base_model"] = base_model output = "" if hasattr(self.planner, "_training_output"): output_path = self.planner._training_output(trainer, model_name) output = str(output_path) if output_path else "" command = TrainingCommand.from_dict({ "action": "train", "trainer": trainer, "dataset": dataset.path, "model_name": model_name, "epochs": epochs, "output": output, "base_model": base_model, "trigger_word": trigger_word, "training_options": options, }) if not hasattr(self.planner, "_plan_training_command"): raise RemoteApiError("Structured training validation is not available.", status=503) plan = self.planner._plan_training_command("Remote structured training", command) append_preflight_summary(plan, self.config) return plan def training_plan(self, payload: dict[str, Any]) -> dict[str, Any]: plan = self._build_training_plan(payload) return self._plan_payload(plan) def start_training(self, payload: dict[str, Any]) -> dict[str, Any]: if self.jobs is None: raise RemoteApiError("Jobs are not available in this ADAM session.", status=503) plan = self._build_training_plan(payload) if not plan.steps: raise RemoteApiError(plan.summary or "ADAM could not build a runnable training plan.") job = self.dispatcher.submit_job(self.jobs, plan) auto_approved = False if self.auto_approve_training(plan): self.dispatcher.confirm_job(self.jobs, job.id) auto_approved = True try: dataset = self._asset_from_id(bounded_text(payload.get("dataset_id"), max_length=4000, label="Dataset"), kind="dataset") self._dataset_registry().touch(dataset.path) except Exception: pass return { "message": f"Queued {job.plan.project_name}.", "job_id": job.id, "requires_approval": bool(plan.requires_confirmation and not auto_approved), "auto_approved": auto_approved, "plan": self._plan_payload(plan), } def generation_schema(self) -> dict[str, Any]: registry = self._registry() providers = [] for tool in generation_tools(registry): providers.append({ "id": tool.id, "name": tool.name, "model_trainers": tool.model_trainers, "requires_confirmation": tool.requires_confirmation, "options": tool.generation_options, "settings": registry.model_plugins.generation_schema_for_tool(tool.id), }) models = self.models() return { "providers": providers, "models": [item for item in models if item["kind"] == "model"], "base_models": [item for item in models if item["kind"] == "base_model"], } def start_generation(self, payload: dict[str, Any]) -> dict[str, Any]: if self.jobs is None: raise RemoteApiError("Jobs are not available in this ADAM session.", status=503) registry = self._registry() provider_id = bounded_text(payload.get("provider_id", ""), max_length=96, label="Provider", required=True) tool = next((item for item in generation_tools(registry) if item.id == provider_id), None) if tool is None: raise RemoteApiError("Unknown generation provider.", status=404) model = self._asset_from_id(bounded_text(payload.get("model_id", ""), max_length=4000, label="Model", required=True), kind="model") if model.trainer not in tool.model_trainers: raise RemoteApiError("That model is not compatible with the selected provider.") prompt = bounded_text(payload.get("prompt", ""), max_length=2000, label="Prompt") count = bounded_int(payload.get("image_count"), minimum=1, maximum=32, default=1, label="Image count") if provider_id == "lora_generator": count = min(count, 8) options = tool.generation_options step_max = int(options.get("step_max", 500) or 500) steps = bounded_int(payload.get("steps"), minimum=1, maximum=step_max, default=int(options.get("step_default", 30) or 30), label="Steps") seed = bounded_int(payload.get("seed"), minimum=0, maximum=2_147_483_647, default=0, label="Seed") sampler_options = [str(item) for item in options.get("samplers", [])] sampler = bounded_text(payload.get("sampler") or (sampler_options[0] if sampler_options else "DDIM"), max_length=64, label="Sampler") if sampler_options and sampler not in sampler_options: raise RemoteApiError("Sampler is not supported by that provider.") aspect_options = [str(item) for item in options.get("aspect_ratios", [])] aspect = bounded_text(payload.get("aspect_ratio") or (aspect_options[0] if aspect_options else "1:1 (Square)"), max_length=64, label="Aspect ratio") if aspect_options and aspect not in aspect_options: raise RemoteApiError("Aspect ratio is not supported by that provider.") extra: dict[str, Any] = {} if provider_id == "lora_generator": base_id = bounded_text(payload.get("base_model_id", ""), max_length=4000, label="Base model") base_model_path = self._asset_from_id(base_id, kind="base_model").path if base_id else "" extra.update({ "negative_prompt": bounded_text(payload.get("negative_prompt", ""), max_length=3000, label="Negative prompt"), "base_model_path": base_model_path, "width": bounded_int(payload.get("width"), minimum=0, maximum=2048, default=0, label="Width"), "height": bounded_int(payload.get("height"), minimum=0, maximum=2048, default=0, label="Height"), "cfg_scale": bounded_float(payload.get("cfg_scale"), minimum=0.0, maximum=30.0, default=0.0, label="CFG scale"), "lora_strength": bounded_float(payload.get("lora_strength"), minimum=0.0, maximum=3.0, default=1.0, label="LoRA strength"), "denoise_strength": bounded_float(payload.get("denoise_strength"), minimum=0.0, maximum=1.0, default=0.0, label="Denoise strength"), "prompt_weighting": bool(payload.get("prompt_weighting", True)), }) elif provider_id == "ddpm_generator": extra.update({ "reference_strength": bounded_int(payload.get("reference_strength"), minimum=0, maximum=100, default=65, label="Reference strength"), "width": bounded_int(payload.get("width"), minimum=0, maximum=2048, default=0, label="Width"), "height": bounded_int(payload.get("height"), minimum=0, maximum=2048, default=0, label="Height"), }) plugin_settings = payload.get("settings") or {} if isinstance(plugin_settings, dict): extra.update(plugin_settings) plan = build_generation_plan( tool, model_name=model.name, model_path=model.path, prompt=prompt, image_count=count, steps=steps, seed=seed, sampler=sampler, aspect_ratio=aspect, extra_arguments=extra, ) job = self.dispatcher.submit_job(self.jobs, plan) return { "message": f"Queued {job.plan.project_name}.", "job_id": job.id, "requires_approval": bool(plan.requires_confirmation), "plan": self._plan_payload(plan), } def job_detail(self, job_id: str) -> dict[str, Any]: if self.jobs is None or not hasattr(self.jobs, "get"): raise RemoteApiError("Jobs are not available in this ADAM session.", status=503) job = self.jobs.get(job_id) if job is None: raise RemoteApiError("ADAM could not find that job.", status=404) return { "id": job.id, "project": job.plan.project_name, "status": job.status.value, "progress": job.progress, "current_step": job.current_step, "logs": list(job.logs)[-40:], "steps": [ { "tool_id": step.tool_id, "title": step.title, "description": step.description, "status": step.status.value, "arguments": sanitized_arguments(dict(step.arguments)), } for step in job.plan.steps ], } @staticmethod def _plan_payload(plan: Any) -> dict[str, Any]: return { "id": getattr(plan, "id", ""), "summary": getattr(plan, "summary", ""), "project_name": getattr(plan, "project_name", ""), "requires_confirmation": bool(getattr(plan, "requires_confirmation", False)), "confirmation_reason": getattr(plan, "confirmation_reason", ""), "steps": [ { "tool_id": step.tool_id, "title": step.title, "description": step.description, "arguments": sanitized_arguments(dict(step.arguments)), } for step in getattr(plan, "steps", []) ], }