From 19650f6ada297d5766e4a89d9eb21f33898c6160 Mon Sep 17 00:00:00 2001 From: CypherNaught-0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:57:59 +0100 Subject: [PATCH 01/13] feat: initial external model support --- docs/contributing/EXTERNAL_PROVIDERS.md | 57 +++ docs/contributing/index.md | 4 + invokeai/app/api/dependencies.py | 10 + invokeai/app/api/routers/app_info.py | 138 ++++++- invokeai/app/api/routers/model_manager.py | 16 +- .../invocations/external_image_generation.py | 148 ++++++++ .../app/services/config/config_default.py | 10 + .../services/external_generation/__init__.py | 23 ++ .../services/external_generation/errors.py | 18 + .../external_generation_base.py | 40 ++ .../external_generation_common.py | 55 +++ .../external_generation_default.py | 291 +++++++++++++++ .../external_generation/image_utils.py | 19 + .../external_generation/providers/__init__.py | 4 + .../external_generation/providers/gemini.py | 249 +++++++++++++ .../external_generation/providers/openai.py | 105 ++++++ invokeai/app/services/invocation_services.py | 3 + .../model_install/model_install_common.py | 17 +- .../model_install/model_install_default.py | 72 +++- .../model_records/model_records_base.py | 19 +- .../app/services/shared/invocation_context.py | 8 + .../model_manager/configs/external_api.py | 80 ++++ .../backend/model_manager/configs/factory.py | 2 + .../backend/model_manager/starter_models.py | 112 ++++++ invokeai/backend/model_manager/taxonomy.py | 4 + invokeai/frontend/web/public/locales/en.json | 38 +- .../components/StagingArea/context.tsx | 11 +- .../components/StagingArea/shared.test.ts | 27 ++ .../components/StagingArea/shared.ts | 5 +- .../controlLayers/store/paramsSlice.test.ts | 61 +++ .../controlLayers/store/paramsSlice.ts | 74 ++-- .../controlLayers/store/validators.ts | 12 +- .../web/src/features/modelManagerV2/models.ts | 13 +- .../store/installModelsStore.ts | 7 +- .../ExternalProvidersForm.tsx | 281 ++++++++++++++ .../LaunchpadForm/LaunchpadForm.tsx | 12 +- .../subpanels/InstallModels.tsx | 21 +- .../ModelManagerPanel/ModelFormatBadge.tsx | 2 + .../ModelPanel/Fields/BaseModelSelect.tsx | 4 +- .../ModelPanel/Fields/ModelFormatSelect.tsx | 4 +- .../ModelPanel/Fields/ModelTypeSelect.tsx | 4 +- .../ModelPanel/Fields/ModelVariantSelect.tsx | 4 +- .../Fields/PredictionTypeSelect.tsx | 4 +- .../subpanels/ModelPanel/ModelEdit.tsx | 192 +++++++++- .../subpanels/ModelPanel/ModelView.tsx | 23 +- .../web/src/features/nodes/types/common.ts | 3 + .../generation/buildExternalGraph.test.ts | 154 ++++++++ .../graph/generation/buildExternalGraph.ts | 129 +++++++ .../Bbox/BboxAspectRatioSelect.test.tsx | 44 +++ .../components/Bbox/BboxAspectRatioSelect.tsx | 5 +- .../DimensionsAspectRatioSelect.test.tsx | 44 +++ .../DimensionsAspectRatioSelect.tsx | 10 +- .../MainModel/mainModelPickerUtils.test.ts | 61 +++ .../MainModel/mainModelPickerUtils.ts | 14 + .../parameters/components/ModelPicker.tsx | 24 +- .../features/queue/hooks/useEnqueueCanvas.ts | 3 + .../queue/hooks/useEnqueueGenerate.ts | 3 + .../web/src/features/queue/store/readiness.ts | 23 +- .../MainModelPicker.tsx | 11 +- .../ExternalProviderStatusList.tsx | 39 ++ .../SettingsModal/SettingsModal.tsx | 8 +- .../externalProviderStatusUtils.test.ts | 38 ++ .../externalProviderStatusUtils.ts | 26 ++ .../layouts/InitialStateMainModelPicker.tsx | 11 +- .../web/src/services/api/endpoints/appInfo.ts | 40 +- .../src/services/api/hooks/modelsByType.ts | 11 +- .../frontend/web/src/services/api/schema.ts | 10 +- .../frontend/web/src/services/api/types.ts | 51 +++ .../test_external_image_generation.py | 120 ++++++ tests/app/routers/test_app_info.py | 93 +++++ tests/app/routers/test_model_manager.py | 71 ++++ .../test_external_generation_service.py | 243 ++++++++++++ .../test_external_provider_adapters.py | 346 ++++++++++++++++++ .../model_install/test_model_install.py | 16 + .../app/services/model_load/test_load_api.py | 23 ++ .../model_manager/test_external_api_config.py | 54 +++ tests/conftest.py | 2 + 77 files changed, 3923 insertions(+), 110 deletions(-) create mode 100644 docs/contributing/EXTERNAL_PROVIDERS.md create mode 100644 invokeai/app/invocations/external_image_generation.py create mode 100644 invokeai/app/services/external_generation/__init__.py create mode 100644 invokeai/app/services/external_generation/errors.py create mode 100644 invokeai/app/services/external_generation/external_generation_base.py create mode 100644 invokeai/app/services/external_generation/external_generation_common.py create mode 100644 invokeai/app/services/external_generation/external_generation_default.py create mode 100644 invokeai/app/services/external_generation/image_utils.py create mode 100644 invokeai/app/services/external_generation/providers/__init__.py create mode 100644 invokeai/app/services/external_generation/providers/gemini.py create mode 100644 invokeai/app/services/external_generation/providers/openai.py create mode 100644 invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts create mode 100644 invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx create mode 100644 invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.test.tsx create mode 100644 invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts create mode 100644 invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/ExternalProviderStatusList.tsx create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.test.ts create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.ts create mode 100644 tests/app/invocations/test_external_image_generation.py create mode 100644 tests/app/routers/test_app_info.py create mode 100644 tests/app/routers/test_model_manager.py create mode 100644 tests/app/services/external_generation/test_external_generation_service.py create mode 100644 tests/app/services/external_generation/test_external_provider_adapters.py create mode 100644 tests/backend/model_manager/test_external_api_config.py diff --git a/docs/contributing/EXTERNAL_PROVIDERS.md b/docs/contributing/EXTERNAL_PROVIDERS.md new file mode 100644 index 00000000000..c2371971db9 --- /dev/null +++ b/docs/contributing/EXTERNAL_PROVIDERS.md @@ -0,0 +1,57 @@ +# External Provider Integration + +This guide covers how to add new external image generation providers and model configs. + +## Provider Adapter Steps + +1) Create a provider adapter in `invokeai/app/services/external_generation/providers/` that inherits from `ExternalProvider`. +2) Implement `is_configured()` using `InvokeAIAppConfig` fields, and `generate()` to map `ExternalGenerationRequest` to the provider API. +3) Use helpers from `invokeai/app/services/external_generation/image_utils.py` for image encoding/decoding. +4) Raise `ExternalProviderRequestError` on non-200 responses or empty payloads. +5) Register the provider in `invokeai/app/api/dependencies.py` when building the `ExternalGenerationService` registry. + +## Config + Env Vars + +Add provider API keys to `InvokeAIAppConfig` with the `INVOKEAI_` prefix: + +- `INVOKEAI_EXTERNAL_GEMINI_API_KEY` +- `INVOKEAI_EXTERNAL_OPENAI_API_KEY` + +These can also be set in `invokeai.yaml` under `external_gemini_api_key` and `external_openai_api_key`. + +## Example External Model Config + +External models are stored in the model manager like any other config. This example can be used as the `config` payload +for `POST /api/v2/models/install?source=external://openai/gpt-image-1`: + +```json +{ + "key": "openai_gpt_image_1", + "name": "OpenAI GPT-Image-1", + "base": "external", + "type": "external_image_generator", + "format": "external_api", + "provider_id": "openai", + "provider_model_id": "gpt-image-1", + "capabilities": { + "modes": ["txt2img", "img2img", "inpaint"], + "supports_negative_prompt": true, + "supports_seed": true, + "supports_guidance": true, + "supports_reference_images": false, + "max_images_per_request": 1 + }, + "default_settings": { + "width": 1024, + "height": 1024, + "steps": 30 + }, + "tags": ["external", "openai"], + "is_default": false +} +``` + +Notes: + +- `path`, `source`, and `hash` will auto-populate if omitted. +- Set `capabilities` conservatively; the external generation service enforces them at runtime. diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 79c1082746d..b8002a18024 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -8,6 +8,10 @@ We welcome contributions, whether features, bug fixes, code cleanup, testing, co If you’d like to help with development, please see our [development guide](contribution_guides/development.md). +## External Providers + +If you are adding external image generation providers or configs, see our [external provider integration guide](EXTERNAL_PROVIDERS.md). + **New Contributors:** If you’re unfamiliar with contributing to open source projects, take a look at our [new contributor guide](contribution_guides/newContributorChecklist.md). ## Nodes diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 339a0ceadb4..ccb9388abcc 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -15,6 +15,8 @@ from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.download.download_default import DownloadQueueService +from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService +from invokeai.app.services.external_generation.providers import GeminiProvider, OpenAIProvider from invokeai.app.services.events.events_fastapievents import FastAPIEventService from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage @@ -145,6 +147,13 @@ def initialize( ), ) download_queue_service = DownloadQueueService(app_config=configuration, event_bus=events) + external_generation = ExternalGenerationService( + providers={ + GeminiProvider.provider_id: GeminiProvider(app_config=configuration, logger=logger), + OpenAIProvider.provider_id: OpenAIProvider(app_config=configuration, logger=logger), + }, + logger=logger, + ) model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images") model_manager = ModelManagerService.build_model_manager( app_config=configuration, @@ -184,6 +193,7 @@ def initialize( model_relationships=model_relationships, model_relationship_records=model_relationship_records, download_queue=download_queue_service, + external_generation=external_generation, names=names, performance_statistics=performance_statistics, session_processor=session_processor, diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index d8f3bb2f807..cbb3e6fb47d 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -2,12 +2,18 @@ from importlib.metadata import distributions import torch -from fastapi import Body +from fastapi import Body, HTTPException, Path from fastapi.routing import APIRouter from pydantic import BaseModel, Field from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.config.config_default import InvokeAIAppConfig, get_config +from invokeai.app.services.config.config_default import ( + DefaultInvokeAIAppConfig, + InvokeAIAppConfig, + get_config, + load_and_migrate_config, +) +from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch from invokeai.backend.util.logging import logging @@ -41,7 +47,7 @@ async def get_version() -> AppVersion: async def get_app_deps() -> dict[str, str]: deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()} try: - cuda = torch.version.cuda or "N/A" + cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue] except Exception: cuda = "N/A" @@ -64,6 +70,29 @@ class InvokeAIAppConfigWithSetFields(BaseModel): config: InvokeAIAppConfig = Field(description="The InvokeAI App Config") +class ExternalProviderStatusModel(BaseModel): + provider_id: str = Field(description="The external provider identifier") + configured: bool = Field(description="Whether credentials are configured for the provider") + message: str | None = Field(default=None, description="Optional provider status detail") + + +class ExternalProviderConfigUpdate(BaseModel): + api_key: str | None = Field(default=None, description="API key for the external provider") + base_url: str | None = Field(default=None, description="Optional base URL override for the provider") + + +class ExternalProviderConfigModel(BaseModel): + provider_id: str = Field(description="The external provider identifier") + api_key_configured: bool = Field(description="Whether an API key is configured") + base_url: str | None = Field(default=None, description="Optional base URL override") + + +EXTERNAL_PROVIDER_FIELDS: dict[str, tuple[str, str]] = { + "gemini": ("external_gemini_api_key", "external_gemini_base_url"), + "openai": ("external_openai_api_key", "external_openai_base_url"), +} + + @app_router.get( "/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields ) @@ -72,6 +101,109 @@ async def get_runtime_config() -> InvokeAIAppConfigWithSetFields: return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config) +@app_router.get( + "/external_providers/status", + operation_id="get_external_provider_statuses", + status_code=200, + response_model=list[ExternalProviderStatusModel], +) +async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]: + statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses() + return [status_to_model(status) for status in statuses.values()] + + +@app_router.get( + "/external_providers/config", + operation_id="get_external_provider_configs", + status_code=200, + response_model=list[ExternalProviderConfigModel], +) +async def get_external_provider_configs() -> list[ExternalProviderConfigModel]: + config = get_config() + return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS] + + +@app_router.post( + "/external_providers/config/{provider_id}", + operation_id="set_external_provider_config", + status_code=200, + response_model=ExternalProviderConfigModel, +) +async def set_external_provider_config( + provider_id: str = Path(description="The external provider identifier"), + update: ExternalProviderConfigUpdate = Body(description="External provider configuration settings"), +) -> ExternalProviderConfigModel: + api_key_field, base_url_field = _get_external_provider_fields(provider_id) + updates: dict[str, str | None] = {} + + if update.api_key is not None: + api_key = update.api_key.strip() + updates[api_key_field] = api_key or None + if update.base_url is not None: + base_url = update.base_url.strip() + updates[base_url_field] = base_url or None + + if not updates: + raise HTTPException(status_code=400, detail="No external provider config fields provided") + + _apply_external_provider_update(updates) + return _build_external_provider_config(provider_id, get_config()) + + +@app_router.delete( + "/external_providers/config/{provider_id}", + operation_id="reset_external_provider_config", + status_code=200, + response_model=ExternalProviderConfigModel, +) +async def reset_external_provider_config( + provider_id: str = Path(description="The external provider identifier"), +) -> ExternalProviderConfigModel: + api_key_field, base_url_field = _get_external_provider_fields(provider_id) + _apply_external_provider_update({api_key_field: None, base_url_field: None}) + return _build_external_provider_config(provider_id, get_config()) + + +def status_to_model(status: ExternalProviderStatus) -> ExternalProviderStatusModel: + return ExternalProviderStatusModel( + provider_id=status.provider_id, + configured=status.configured, + message=status.message, + ) + + +def _get_external_provider_fields(provider_id: str) -> tuple[str, str]: + if provider_id not in EXTERNAL_PROVIDER_FIELDS: + raise HTTPException(status_code=404, detail=f"Unknown external provider '{provider_id}'") + return EXTERNAL_PROVIDER_FIELDS[provider_id] + + +def _apply_external_provider_update(updates: dict[str, str | None]) -> None: + runtime_config = get_config() + config_path = runtime_config.config_file_path + if config_path.exists(): + file_config = load_and_migrate_config(config_path) + else: + file_config = DefaultInvokeAIAppConfig() + + for config in (runtime_config, file_config): + config.update_config(updates) + for field_name, value in updates.items(): + if value is None: + config.model_fields_set.discard(field_name) + + file_config.write_file(config_path, as_example=False) + + +def _build_external_provider_config(provider_id: str, config: InvokeAIAppConfig) -> ExternalProviderConfigModel: + api_key_field, base_url_field = _get_external_provider_fields(provider_id) + return ExternalProviderConfigModel( + provider_id=provider_id, + api_key_configured=bool(getattr(config, api_key_field)), + base_url=getattr(config, base_url_field), + ) + + @app_router.get( "/logging", operation_id="get_log_level", diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index a1f6b3a744a..4e1b14d18c1 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -30,6 +30,7 @@ ) from invokeai.app.services.orphaned_models import OrphanedModelInfo from invokeai.app.util.suppress_output import SuppressOutput +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig from invokeai.backend.model_manager.configs.factory import AnyModelConfig, ModelConfigFactory from invokeai.backend.model_manager.configs.main import ( Main_Checkpoint_SD1_Config, @@ -145,8 +146,16 @@ async def list_model_records( found_models.extend( record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format) ) - for model in found_models: + for index, model in enumerate(found_models): model = add_cover_image_to_model_config(model, ApiDependencies) + if isinstance(model, ExternalApiModelConfig): + starter_match = next((starter for starter in STARTER_MODELS if starter.source == model.source), None) + if starter_match is not None: + if starter_match.capabilities is not None: + setattr(model, "capabilities", starter_match.capabilities) + if starter_match.default_settings is not None: + setattr(model, "default_settings", starter_match.default_settings) + found_models[index] = model return ModelsList(models=found_models) @@ -166,6 +175,8 @@ async def list_missing_models() -> ModelsList: missing_models: list[AnyModelConfig] = [] for model_config in record_store.all_models(): + if model_config.base == BaseModelType.External or model_config.format == ModelFormat.ExternalApi: + continue if not (models_path / model_config.path).resolve().exists(): missing_models.append(model_config) @@ -250,7 +261,8 @@ async def reidentify_model( result.config.name = config.name result.config.description = config.description result.config.cover_image = config.cover_image - result.config.trigger_phrases = config.trigger_phrases + if hasattr(result.config, "trigger_phrases") and hasattr(config, "trigger_phrases"): + setattr(result.config, "trigger_phrases", getattr(config, "trigger_phrases")) result.config.source = config.source result.config.source_type = config.source_type diff --git a/invokeai/app/invocations/external_image_generation.py b/invokeai/app/invocations/external_image_generation.py new file mode 100644 index 00000000000..c70ecb40795 --- /dev/null +++ b/invokeai/app/invocations/external_image_generation.py @@ -0,0 +1,148 @@ +from typing import Any + +from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.fields import ( + FieldDescriptions, + ImageField, + InputField, + MetadataField, + WithBoard, + WithMetadata, +) +from invokeai.app.invocations.model import ModelIdentifierField +from invokeai.app.invocations.primitives import ImageCollectionOutput +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGenerationRequest, + ExternalGenerationResult, + ExternalReferenceImage, +) +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalGenerationMode +from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType + + +@invocation( + "external_image_generation", + title="External Image Generation", + tags=["external", "generation"], + category="image", + version="1.0.0", +) +class ExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBoard): + """Generate images using an external provider.""" + + model: ModelIdentifierField = InputField( + description=FieldDescriptions.main_model, + ui_model_base=[BaseModelType.External], + ui_model_type=[ModelType.ExternalImageGenerator], + ui_model_format=[ModelFormat.ExternalApi], + ) + mode: ExternalGenerationMode = InputField(default="txt2img", description="Generation mode") + prompt: str = InputField(description="Prompt") + negative_prompt: str | None = InputField(default=None, description="Negative prompt") + seed: int | None = InputField(default=None, description=FieldDescriptions.seed) + num_images: int = InputField(default=1, gt=0, description="Number of images to generate") + width: int = InputField(default=1024, gt=0, description=FieldDescriptions.width) + height: int = InputField(default=1024, gt=0, description=FieldDescriptions.height) + steps: int | None = InputField(default=None, gt=0, description=FieldDescriptions.steps) + guidance: float | None = InputField(default=None, ge=0, description="Guidance strength") + init_image: ImageField | None = InputField(default=None, description="Init image for img2img/inpaint") + mask_image: ImageField | None = InputField(default=None, description="Mask image for inpaint") + reference_images: list[ImageField] = InputField(default=[], description="Reference images") + reference_image_weights: list[float] | None = InputField(default=None, description="Reference image weights") + reference_image_modes: list[str] | None = InputField(default=None, description="Reference image modes") + + def invoke(self, context: InvocationContext) -> ImageCollectionOutput: + model_config = context.models.get_config(self.model) + if not isinstance(model_config, ExternalApiModelConfig): + raise ValueError("Selected model is not an external API model") + + init_image = None + if self.init_image is not None: + init_image = context.images.get_pil(self.init_image.image_name, mode="RGB") + + mask_image = None + if self.mask_image is not None: + mask_image = context.images.get_pil(self.mask_image.image_name, mode="L") + + if self.reference_image_weights is not None and len(self.reference_image_weights) != len(self.reference_images): + raise ValueError("reference_image_weights must match reference_images length") + + if self.reference_image_modes is not None and len(self.reference_image_modes) != len(self.reference_images): + raise ValueError("reference_image_modes must match reference_images length") + + reference_images: list[ExternalReferenceImage] = [] + for index, image_field in enumerate(self.reference_images): + reference_image = context.images.get_pil(image_field.image_name, mode="RGB") + weight = None + mode = None + if self.reference_image_weights is not None: + weight = self.reference_image_weights[index] + if self.reference_image_modes is not None: + mode = self.reference_image_modes[index] + reference_images.append(ExternalReferenceImage(image=reference_image, weight=weight, mode=mode)) + + request = ExternalGenerationRequest( + model=model_config, + mode=self.mode, + prompt=self.prompt, + negative_prompt=self.negative_prompt, + seed=self.seed, + num_images=self.num_images, + width=self.width, + height=self.height, + steps=self.steps, + guidance=self.guidance, + init_image=init_image, + mask_image=mask_image, + reference_images=reference_images, + metadata=self._build_request_metadata(), + ) + + result = context._services.external_generation.generate(request) + + outputs: list[ImageField] = [] + for generated in result.images: + metadata = self._build_output_metadata(model_config, result, generated.seed) + image_dto = context.images.save(image=generated.image, metadata=metadata) + outputs.append(ImageField(image_name=image_dto.image_name)) + + return ImageCollectionOutput(collection=outputs) + + def _build_request_metadata(self) -> dict[str, Any] | None: + if self.metadata is None: + return None + return self.metadata.root + + def _build_output_metadata( + self, + model_config: ExternalApiModelConfig, + result: ExternalGenerationResult, + image_seed: int | None, + ) -> MetadataField | None: + metadata: dict[str, Any] = {} + + if self.metadata is not None: + metadata.update(self.metadata.root) + + metadata.update( + { + "external_provider": model_config.provider_id, + "external_model_id": model_config.provider_model_id, + } + ) + + provider_request_id = getattr(result, "provider_request_id", None) + if provider_request_id: + metadata["external_request_id"] = provider_request_id + + provider_metadata = getattr(result, "provider_metadata", None) + if provider_metadata: + metadata["external_provider_metadata"] = provider_metadata + + if image_seed is not None: + metadata["external_seed"] = image_seed + + if not metadata: + return None + return MetadataField(root=metadata) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 2cc2aaf273c..2fc2e9710ae 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -207,6 +207,16 @@ class InvokeAIAppConfig(BaseSettings): # MULTIUSER multiuser: bool = Field(default=False, description="Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.") + # EXTERNAL PROVIDERS + external_gemini_api_key: Optional[str] = Field(default=None, description="API key for Gemini image generation.") + external_openai_api_key: Optional[str] = Field(default=None, description="API key for OpenAI image generation.") + external_gemini_base_url: Optional[str] = Field( + default=None, description="Base URL override for Gemini image generation." + ) + external_openai_base_url: Optional[str] = Field( + default=None, description="Base URL override for OpenAI image generation." + ) + # fmt: on model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True) diff --git a/invokeai/app/services/external_generation/__init__.py b/invokeai/app/services/external_generation/__init__.py new file mode 100644 index 00000000000..692da64643a --- /dev/null +++ b/invokeai/app/services/external_generation/__init__.py @@ -0,0 +1,23 @@ +from invokeai.app.services.external_generation.external_generation_base import ( + ExternalGenerationServiceBase, + ExternalProvider, +) +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGenerationRequest, + ExternalGenerationResult, + ExternalGeneratedImage, + ExternalProviderStatus, + ExternalReferenceImage, +) +from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService + +__all__ = [ + "ExternalGenerationRequest", + "ExternalGenerationResult", + "ExternalGeneratedImage", + "ExternalGenerationService", + "ExternalGenerationServiceBase", + "ExternalProvider", + "ExternalProviderStatus", + "ExternalReferenceImage", +] diff --git a/invokeai/app/services/external_generation/errors.py b/invokeai/app/services/external_generation/errors.py new file mode 100644 index 00000000000..9980b39bc43 --- /dev/null +++ b/invokeai/app/services/external_generation/errors.py @@ -0,0 +1,18 @@ +class ExternalGenerationError(Exception): + """Base error for external generation.""" + + +class ExternalProviderNotFoundError(ExternalGenerationError): + """Raised when no provider is registered for a model.""" + + +class ExternalProviderNotConfiguredError(ExternalGenerationError): + """Raised when a provider is missing required credentials.""" + + +class ExternalProviderCapabilityError(ExternalGenerationError): + """Raised when a request is not supported by provider capabilities.""" + + +class ExternalProviderRequestError(ExternalGenerationError): + """Raised when a provider rejects the request or returns an error.""" diff --git a/invokeai/app/services/external_generation/external_generation_base.py b/invokeai/app/services/external_generation/external_generation_base.py new file mode 100644 index 00000000000..2145ff5ca42 --- /dev/null +++ b/invokeai/app/services/external_generation/external_generation_base.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from logging import Logger + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGenerationRequest, + ExternalGenerationResult, + ExternalProviderStatus, +) + + +class ExternalProvider(ABC): + provider_id: str + + def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None: + self._app_config = app_config + self._logger = logger + + @abstractmethod + def is_configured(self) -> bool: + raise NotImplementedError + + @abstractmethod + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + raise NotImplementedError + + def get_status(self) -> ExternalProviderStatus: + return ExternalProviderStatus(provider_id=self.provider_id, configured=self.is_configured()) + + +class ExternalGenerationServiceBase(ABC): + @abstractmethod + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + raise NotImplementedError + + @abstractmethod + def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]: + raise NotImplementedError diff --git a/invokeai/app/services/external_generation/external_generation_common.py b/invokeai/app/services/external_generation/external_generation_common.py new file mode 100644 index 00000000000..c1e2f4706f5 --- /dev/null +++ b/invokeai/app/services/external_generation/external_generation_common.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PIL.Image import Image as PILImageType + +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalGenerationMode + + +@dataclass(frozen=True) +class ExternalReferenceImage: + image: PILImageType + weight: float | None = None + mode: str | None = None + + +@dataclass(frozen=True) +class ExternalGenerationRequest: + model: ExternalApiModelConfig + mode: ExternalGenerationMode + prompt: str + negative_prompt: str | None + seed: int | None + num_images: int + width: int + height: int + steps: int | None + guidance: float | None + init_image: PILImageType | None + mask_image: PILImageType | None + reference_images: list[ExternalReferenceImage] + metadata: dict[str, Any] | None + + +@dataclass(frozen=True) +class ExternalGeneratedImage: + image: PILImageType + seed: int | None = None + + +@dataclass(frozen=True) +class ExternalGenerationResult: + images: list[ExternalGeneratedImage] + seed_used: int | None = None + provider_request_id: str | None = None + provider_metadata: dict[str, Any] | None = None + content_filters: dict[str, str] | None = None + + +@dataclass(frozen=True) +class ExternalProviderStatus: + provider_id: str + configured: bool + message: str | None = None diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py new file mode 100644 index 00000000000..c72e16cde8d --- /dev/null +++ b/invokeai/app/services/external_generation/external_generation_default.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from logging import Logger + +from PIL import Image +from PIL.Image import Image as PILImageType + +from invokeai.app.services.external_generation.errors import ( + ExternalProviderCapabilityError, + ExternalProviderNotConfiguredError, + ExternalProviderNotFoundError, +) +from invokeai.app.services.external_generation.external_generation_base import ( + ExternalGenerationServiceBase, + ExternalProvider, +) +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGenerationRequest, + ExternalGenerationResult, + ExternalProviderStatus, +) +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalImageSize +from invokeai.backend.model_manager.starter_models import STARTER_MODELS + + +class ExternalGenerationService(ExternalGenerationServiceBase): + def __init__(self, providers: dict[str, ExternalProvider], logger: Logger) -> None: + self._providers = providers + self._logger = logger + + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + provider = self._providers.get(request.model.provider_id) + if provider is None: + raise ExternalProviderNotFoundError(f"No external provider registered for '{request.model.provider_id}'") + + if not provider.is_configured(): + raise ExternalProviderNotConfiguredError(f"Provider '{request.model.provider_id}' is missing credentials") + + request = self._refresh_model_capabilities(request) + request = self._bucket_request(request) + + self._validate_request(request) + return provider.generate(request) + + def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]: + return {provider_id: provider.get_status() for provider_id, provider in self._providers.items()} + + def _validate_request(self, request: ExternalGenerationRequest) -> None: + capabilities = request.model.capabilities + + self._logger.debug( + "Validating external request provider=%s model=%s mode=%s supported=%s", + request.model.provider_id, + request.model.provider_model_id, + request.mode, + capabilities.modes, + ) + + if request.mode not in capabilities.modes: + raise ExternalProviderCapabilityError(f"Mode '{request.mode}' is not supported by {request.model.name}") + + if request.negative_prompt and not capabilities.supports_negative_prompt: + raise ExternalProviderCapabilityError(f"Negative prompts are not supported by {request.model.name}") + + if request.seed is not None and not capabilities.supports_seed: + raise ExternalProviderCapabilityError(f"Seed control is not supported by {request.model.name}") + + if request.guidance is not None and not capabilities.supports_guidance: + raise ExternalProviderCapabilityError(f"Guidance is not supported by {request.model.name}") + + if request.reference_images and not capabilities.supports_reference_images: + raise ExternalProviderCapabilityError(f"Reference images are not supported by {request.model.name}") + + if capabilities.max_reference_images is not None: + if len(request.reference_images) > capabilities.max_reference_images: + raise ExternalProviderCapabilityError( + f"{request.model.name} supports at most {capabilities.max_reference_images} reference images" + ) + + if capabilities.max_images_per_request is not None and request.num_images > capabilities.max_images_per_request: + raise ExternalProviderCapabilityError( + f"{request.model.name} supports at most {capabilities.max_images_per_request} images per request" + ) + + if capabilities.max_image_size is not None: + if request.width > capabilities.max_image_size.width or request.height > capabilities.max_image_size.height: + raise ExternalProviderCapabilityError( + f"{request.model.name} supports a maximum size of {capabilities.max_image_size.width}x{capabilities.max_image_size.height}" + ) + + if capabilities.allowed_aspect_ratios: + aspect_ratio = _format_aspect_ratio(request.width, request.height) + if aspect_ratio not in capabilities.allowed_aspect_ratios: + size_ratio = None + if capabilities.aspect_ratio_sizes: + size_ratio = _ratio_for_size(request.width, request.height, capabilities.aspect_ratio_sizes) + if size_ratio is None or size_ratio not in capabilities.allowed_aspect_ratios: + ratio_label = size_ratio or aspect_ratio + raise ExternalProviderCapabilityError( + f"{request.model.name} does not support aspect ratio {ratio_label}" + ) + + required_modes = capabilities.input_image_required_for or ["img2img", "inpaint"] + if request.mode in required_modes and request.init_image is None: + raise ExternalProviderCapabilityError( + f"Mode '{request.mode}' requires an init image for {request.model.name}" + ) + + if request.mode == "inpaint" and request.mask_image is None: + raise ExternalProviderCapabilityError( + f"Mode '{request.mode}' requires a mask image for {request.model.name}" + ) + + def _refresh_model_capabilities(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest: + try: + from invokeai.app.api.dependencies import ApiDependencies + + record = ApiDependencies.invoker.services.model_manager.store.get_model(request.model.key) + except Exception: + record = None + + if not isinstance(record, ExternalApiModelConfig): + return request + + if record.key != request.model.key: + return request + + if record.provider_id != request.model.provider_id: + return request + + if record.provider_model_id != request.model.provider_model_id: + return request + + record = _apply_starter_overrides(record) + + if record == request.model: + return request + + return ExternalGenerationRequest( + model=record, + mode=request.mode, + prompt=request.prompt, + negative_prompt=request.negative_prompt, + seed=request.seed, + num_images=request.num_images, + width=request.width, + height=request.height, + steps=request.steps, + guidance=request.guidance, + init_image=request.init_image, + mask_image=request.mask_image, + reference_images=request.reference_images, + metadata=request.metadata, + ) + + def _bucket_request(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest: + capabilities = request.model.capabilities + if not capabilities.allowed_aspect_ratios: + return request + + aspect_ratio = _format_aspect_ratio(request.width, request.height) + size = None + if capabilities.aspect_ratio_sizes: + size = capabilities.aspect_ratio_sizes.get(aspect_ratio) + + if size is not None: + if request.width == size.width and request.height == size.height: + return request + return self._bucket_to_size(request, size.width, size.height, aspect_ratio) + + if aspect_ratio in capabilities.allowed_aspect_ratios: + return request + + if not capabilities.aspect_ratio_sizes: + return request + + closest = _select_closest_ratio( + request.width, + request.height, + capabilities.allowed_aspect_ratios, + ) + if closest is None: + return request + + size = capabilities.aspect_ratio_sizes.get(closest) + if size is None: + return request + + return self._bucket_to_size(request, size.width, size.height, closest) + + def _bucket_to_size( + self, + request: ExternalGenerationRequest, + width: int, + height: int, + ratio: str, + ) -> ExternalGenerationRequest: + self._logger.info( + "Bucketing external request provider=%s model=%s %sx%s -> %sx%s (ratio %s)", + request.model.provider_id, + request.model.provider_model_id, + request.width, + request.height, + width, + height, + ratio, + ) + + return ExternalGenerationRequest( + model=request.model, + mode=request.mode, + prompt=request.prompt, + negative_prompt=request.negative_prompt, + seed=request.seed, + num_images=request.num_images, + width=width, + height=height, + steps=request.steps, + guidance=request.guidance, + init_image=_resize_image(request.init_image, width, height, "RGB"), + mask_image=_resize_image(request.mask_image, width, height, "L"), + reference_images=request.reference_images, + metadata=request.metadata, + ) + + +def _format_aspect_ratio(width: int, height: int) -> str: + divisor = _gcd(width, height) + return f"{width // divisor}:{height // divisor}" + + +def _select_closest_ratio(width: int, height: int, ratios: list[str]) -> str | None: + ratio = width / height + parsed: list[tuple[str, float]] = [] + for value in ratios: + parsed_ratio = _parse_ratio(value) + if parsed_ratio is not None: + parsed.append((value, parsed_ratio)) + if not parsed: + return None + return min(parsed, key=lambda item: abs(item[1] - ratio))[0] + + +def _ratio_for_size(width: int, height: int, sizes: dict[str, ExternalImageSize]) -> str | None: + for ratio, size in sizes.items(): + if size.width == width and size.height == height: + return ratio + return None + + +def _parse_ratio(value: str) -> float | None: + if ":" not in value: + return None + left, right = value.split(":", 1) + try: + numerator = float(left) + denominator = float(right) + except ValueError: + return None + if denominator == 0: + return None + return numerator / denominator + + +def _gcd(a: int, b: int) -> int: + while b: + a, b = b, a % b + return a + + +def _resize_image(image: PILImageType | None, width: int, height: int, mode: str) -> PILImageType | None: + if image is None: + return None + if image.width == width and image.height == height: + return image + return image.convert(mode).resize((width, height), Image.Resampling.LANCZOS) + + +def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig: + source = model.source or f"external://{model.provider_id}/{model.provider_model_id}" + starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) + if starter_match is None: + return model + updates: dict[str, object] = {} + if starter_match.capabilities is not None: + updates["capabilities"] = starter_match.capabilities + if starter_match.default_settings is not None: + updates["default_settings"] = starter_match.default_settings + if not updates: + return model + return model.model_copy(update=updates) diff --git a/invokeai/app/services/external_generation/image_utils.py b/invokeai/app/services/external_generation/image_utils.py new file mode 100644 index 00000000000..a23c1f11d66 --- /dev/null +++ b/invokeai/app/services/external_generation/image_utils.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import base64 +import io + +from PIL import Image +from PIL.Image import Image as PILImageType + + +def encode_image_base64(image: PILImageType, format: str = "PNG") -> str: + buffer = io.BytesIO() + image.save(buffer, format=format) + return base64.b64encode(buffer.getvalue()).decode("ascii") + + +def decode_image_base64(encoded: str) -> PILImageType: + data = base64.b64decode(encoded) + image = Image.open(io.BytesIO(data)) + return image.convert("RGB") diff --git a/invokeai/app/services/external_generation/providers/__init__.py b/invokeai/app/services/external_generation/providers/__init__.py new file mode 100644 index 00000000000..9e380fca1e1 --- /dev/null +++ b/invokeai/app/services/external_generation/providers/__init__.py @@ -0,0 +1,4 @@ +from invokeai.app.services.external_generation.providers.gemini import GeminiProvider +from invokeai.app.services.external_generation.providers.openai import OpenAIProvider + +__all__ = ["GeminiProvider", "OpenAIProvider"] diff --git a/invokeai/app/services/external_generation/providers/gemini.py b/invokeai/app/services/external_generation/providers/gemini.py new file mode 100644 index 00000000000..4d43431a14a --- /dev/null +++ b/invokeai/app/services/external_generation/providers/gemini.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import json +import uuid + +import requests +from PIL.Image import Image as PILImageType + +from invokeai.app.services.external_generation.errors import ExternalProviderRequestError +from invokeai.app.services.external_generation.external_generation_base import ExternalProvider +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGeneratedImage, + ExternalGenerationRequest, + ExternalGenerationResult, +) +from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64 + + +class GeminiProvider(ExternalProvider): + provider_id = "gemini" + _SYSTEM_INSTRUCTION = ( + "You are an image generation model. Always respond with an image based on the user's prompt. " + "Do not return text-only responses. If the user input is not an edit instruction, " + "interpret it as a request to create a new image." + ) + + def is_configured(self) -> bool: + return bool(self._app_config.external_gemini_api_key) + + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + api_key = self._app_config.external_gemini_api_key + if not api_key: + raise ExternalProviderRequestError("Gemini API key is not configured") + + base_url = (self._app_config.external_gemini_base_url or "https://generativelanguage.googleapis.com").rstrip( + "/" + ) + if not base_url.endswith("/v1") and not base_url.endswith("/v1beta"): + base_url = f"{base_url}/v1beta" + model_id = request.model.provider_model_id.removeprefix("models/") + endpoint = f"{base_url}/models/{model_id}:generateContent" + + request_parts: list[dict[str, object]] = [] + + if request.init_image is not None: + request_parts.append( + { + "inlineData": { + "mimeType": "image/png", + "data": encode_image_base64(request.init_image), + } + } + ) + + request_parts.append({"text": request.prompt}) + + for reference in request.reference_images: + request_parts.append( + { + "inlineData": { + "mimeType": "image/png", + "data": encode_image_base64(reference.image), + } + } + ) + + generation_config: dict[str, object] = { + "candidateCount": request.num_images, + "responseModalities": ["IMAGE"], + } + aspect_ratio = _select_aspect_ratio( + request.width, + request.height, + request.model.capabilities.allowed_aspect_ratios, + ) + system_instruction = self._SYSTEM_INSTRUCTION + if request.init_image is not None: + system_instruction = ( + f"{system_instruction} An input image is provided. " + "Treat the prompt as an edit instruction and modify the image accordingly. " + "Do not return the original image unchanged." + ) + if aspect_ratio is not None: + system_instruction = f"{system_instruction} Use an aspect ratio of {aspect_ratio}." + + payload: dict[str, object] = { + "systemInstruction": {"parts": [{"text": system_instruction}]}, + "contents": [{"role": "user", "parts": request_parts}], + "generationConfig": generation_config, + } + + self._dump_debug_payload("request", payload) + + response = requests.post( + endpoint, + params={"key": api_key}, + json=payload, + timeout=120, + ) + + if not response.ok: + raise ExternalProviderRequestError( + f"Gemini request failed with status {response.status_code} for model '{model_id}': {response.text}" + ) + + data = response.json() + self._dump_debug_payload("response", data) + if not isinstance(data, dict): + raise ExternalProviderRequestError("Gemini response payload was not a JSON object") + images: list[ExternalGeneratedImage] = [] + text_parts: list[str] = [] + finish_messages: list[str] = [] + candidates = data.get("candidates") + if not isinstance(candidates, list): + raise ExternalProviderRequestError("Gemini response payload missing candidates") + for candidate in candidates: + if not isinstance(candidate, dict): + continue + finish_message = candidate.get("finishMessage") + finish_reason = candidate.get("finishReason") + if isinstance(finish_message, str): + finish_messages.append(finish_message) + elif isinstance(finish_reason, str): + finish_messages.append(f"Finish reason: {finish_reason}") + for part in _iter_response_parts(candidate): + inline_data = part.get("inline_data") or part.get("inlineData") + if isinstance(inline_data, dict): + encoded = inline_data.get("data") + if encoded: + image = decode_image_base64(encoded) + images.append(ExternalGeneratedImage(image=image, seed=request.seed)) + self._dump_debug_image(image) + continue + file_data = part.get("fileData") or part.get("file_data") + if isinstance(file_data, dict): + file_uri = file_data.get("fileUri") or file_data.get("file_uri") + if isinstance(file_uri, str) and file_uri: + raise ExternalProviderRequestError( + f"Gemini returned fileUri instead of inline image data: {file_uri}" + ) + text = part.get("text") + if isinstance(text, str): + text_parts.append(text) + + if not images: + self._logger.error("Gemini response contained no images: %s", data) + detail = "" + if finish_messages: + combined = " ".join(message.strip() for message in finish_messages if message.strip()) + if combined: + detail = f" Response status: {combined[:500]}" + elif text_parts: + combined = " ".join(text_parts).strip() + if combined: + detail = f" Response text: {combined[:500]}" + raise ExternalProviderRequestError(f"Gemini response contained no images.{detail}") + + return ExternalGenerationResult( + images=images, + seed_used=request.seed, + provider_metadata={"model": request.model.provider_model_id}, + ) + + def _dump_debug_payload(self, label: str, payload: object) -> None: + """TODO: remove debug payload dump once Gemini is stable.""" + try: + outputs_path = self._app_config.outputs_path + if outputs_path is None: + return + debug_dir = outputs_path / "external_debug" / "gemini" + debug_dir.mkdir(parents=True, exist_ok=True) + path = debug_dir / f"{label}_{uuid.uuid4().hex}.json" + path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + except Exception as exc: + self._logger.debug("Failed to write Gemini debug payload: %s", exc) + + def _dump_debug_image(self, image: "PILImageType") -> None: + """TODO: remove debug image dump once Gemini is stable.""" + try: + outputs_path = self._app_config.outputs_path + if outputs_path is None: + return + debug_dir = outputs_path / "external_debug" / "gemini" + debug_dir.mkdir(parents=True, exist_ok=True) + path = debug_dir / f"decoded_{uuid.uuid4().hex}.png" + image.save(path, format="PNG") + except Exception as exc: + self._logger.debug("Failed to write Gemini debug image: %s", exc) + + +def _iter_response_parts(candidate: dict[str, object]) -> list[dict[str, object]]: + content = candidate.get("content") + if isinstance(content, dict): + content_parts = content.get("parts") + if isinstance(content_parts, list): + return [part for part in content_parts if isinstance(part, dict)] + contents = candidate.get("contents") + if isinstance(contents, list): + parts: list[dict[str, object]] = [] + for item in contents: + if not isinstance(item, dict): + continue + item_parts = item.get("parts") + if isinstance(item_parts, list): + parts.extend([part for part in item_parts if isinstance(part, dict)]) + if parts: + return parts + return [] + + +def _select_aspect_ratio(width: int, height: int, allowed: list[str] | None) -> str | None: + if width <= 0 or height <= 0: + return None + ratio = width / height + default_ratio = _format_aspect_ratio(width, height) + if not allowed: + return default_ratio + parsed = [(value, _parse_ratio(value)) for value in allowed] + filtered = [(value, parsed_ratio) for value, parsed_ratio in parsed if parsed_ratio is not None] + if not filtered: + return default_ratio + return min(filtered, key=lambda item: abs(item[1] - ratio))[0] + + +def _format_aspect_ratio(width: int, height: int) -> str | None: + if width <= 0 or height <= 0: + return None + divisor = _gcd(width, height) + return f"{width // divisor}:{height // divisor}" + + +def _parse_ratio(value: str) -> float | None: + if ":" not in value: + return None + left, right = value.split(":", 1) + try: + numerator = float(left) + denominator = float(right) + except ValueError: + return None + if denominator == 0: + return None + return numerator / denominator + + +def _gcd(a: int, b: int) -> int: + while b: + a, b = b, a % b + return a diff --git a/invokeai/app/services/external_generation/providers/openai.py b/invokeai/app/services/external_generation/providers/openai.py new file mode 100644 index 00000000000..e31a493b7a1 --- /dev/null +++ b/invokeai/app/services/external_generation/providers/openai.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import io + +import requests + +from invokeai.app.services.external_generation.errors import ExternalProviderRequestError +from invokeai.app.services.external_generation.external_generation_base import ExternalProvider +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGeneratedImage, + ExternalGenerationRequest, + ExternalGenerationResult, +) +from invokeai.app.services.external_generation.image_utils import decode_image_base64 + + +class OpenAIProvider(ExternalProvider): + provider_id = "openai" + + def is_configured(self) -> bool: + return bool(self._app_config.external_openai_api_key) + + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + api_key = self._app_config.external_openai_api_key + if not api_key: + raise ExternalProviderRequestError("OpenAI API key is not configured") + + size = f"{request.width}x{request.height}" + base_url = (self._app_config.external_openai_base_url or "https://api.openai.com").rstrip("/") + headers = {"Authorization": f"Bearer {api_key}"} + + if request.mode == "txt2img": + payload: dict[str, object] = { + "prompt": request.prompt, + "n": request.num_images, + "size": size, + "response_format": "b64_json", + } + if request.seed is not None: + payload["seed"] = request.seed + response = requests.post( + f"{base_url}/v1/images/generations", + headers=headers, + json=payload, + timeout=120, + ) + else: + files: dict[str, tuple[str, io.BytesIO, str]] = {} + if request.init_image is None: + raise ExternalProviderRequestError("OpenAI img2img/inpaint requires an init image") + + image_buffer = io.BytesIO() + request.init_image.save(image_buffer, format="PNG") + image_buffer.seek(0) + files["image"] = ("image.png", image_buffer, "image/png") + + if request.mask_image is not None: + mask_buffer = io.BytesIO() + request.mask_image.save(mask_buffer, format="PNG") + mask_buffer.seek(0) + files["mask"] = ("mask.png", mask_buffer, "image/png") + + data: dict[str, object] = { + "prompt": request.prompt, + "n": request.num_images, + "size": size, + "response_format": "b64_json", + } + response = requests.post( + f"{base_url}/v1/images/edits", + headers=headers, + data=data, + files=files, + timeout=120, + ) + + if not response.ok: + raise ExternalProviderRequestError( + f"OpenAI request failed with status {response.status_code}: {response.text}" + ) + + payload = response.json() + if not isinstance(payload, dict): + raise ExternalProviderRequestError("OpenAI response payload was not a JSON object") + images: list[ExternalGeneratedImage] = [] + data_items = payload.get("data") + if not isinstance(data_items, list): + raise ExternalProviderRequestError("OpenAI response payload missing image data") + for item in data_items: + if not isinstance(item, dict): + continue + encoded = item.get("b64_json") + if not encoded: + continue + images.append(ExternalGeneratedImage(image=decode_image_base64(encoded), seed=request.seed)) + + if not images: + raise ExternalProviderRequestError("OpenAI response contained no images") + + return ExternalGenerationResult( + images=images, + seed_used=request.seed, + provider_request_id=response.headers.get("x-request-id"), + provider_metadata={"model": request.model.provider_model_id}, + ) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 7a33f49940c..2c95f87b41d 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -21,6 +21,7 @@ from invokeai.app.services.config import InvokeAIAppConfig from invokeai.app.services.download import DownloadQueueServiceBase from invokeai.app.services.events.events_base import EventServiceBase + from invokeai.app.services.external_generation.external_generation_base import ExternalGenerationServiceBase from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase from invokeai.app.services.images.images_base import ImageServiceABC @@ -63,6 +64,7 @@ def __init__( model_relationships: "ModelRelationshipsServiceABC", model_relationship_records: "ModelRelationshipRecordStorageBase", download_queue: "DownloadQueueServiceBase", + external_generation: "ExternalGenerationServiceBase", performance_statistics: "InvocationStatsServiceBase", session_queue: "SessionQueueBase", session_processor: "SessionProcessorBase", @@ -94,6 +96,7 @@ def __init__( self.model_relationships = model_relationships self.model_relationship_records = model_relationship_records self.download_queue = download_queue + self.external_generation = external_generation self.performance_statistics = performance_statistics self.session_queue = session_queue self.session_processor = session_processor diff --git a/invokeai/app/services/model_install/model_install_common.py b/invokeai/app/services/model_install/model_install_common.py index 1006135a95e..11b7cd1f9dc 100644 --- a/invokeai/app/services/model_install/model_install_common.py +++ b/invokeai/app/services/model_install/model_install_common.py @@ -139,12 +139,27 @@ def __str__(self) -> str: return str(self.url) -ModelSource = Annotated[Union[LocalModelSource, HFModelSource, URLModelSource], Field(discriminator="type")] +class ExternalModelSource(StringLikeSource): + """An external provider model identifier.""" + + provider_id: str + provider_model_id: str + type: Literal["external"] = "external" + + def __str__(self) -> str: + return f"external://{self.provider_id}/{self.provider_model_id}" + + +ModelSource = Annotated[ + Union[LocalModelSource, HFModelSource, URLModelSource, ExternalModelSource], + Field(discriminator="type"), +] MODEL_SOURCE_TO_TYPE_MAP = { URLModelSource: ModelSourceType.Url, HFModelSource: ModelSourceType.HFRepoID, LocalModelSource: ModelSourceType.Path, + ExternalModelSource: ModelSourceType.Url, } diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index c47267eab5f..2176ba926a9 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -28,6 +28,7 @@ from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase from invokeai.app.services.model_install.model_install_common import ( MODEL_SOURCE_TO_TYPE_MAP, + ExternalModelSource, HFModelSource, InstallStatus, InvalidModelConfigException, @@ -45,6 +46,11 @@ AnyModelConfig, ModelConfigFactory, ) +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelConfig, + ExternalApiModelDefaultSettings, + ExternalModelCapabilities, +) from invokeai.backend.model_manager.configs.unknown import Unknown_Config from invokeai.backend.model_manager.metadata import ( AnyModelRepoMetadata, @@ -55,7 +61,7 @@ ) from invokeai.backend.model_manager.metadata.metadata_base import HuggingFaceMetadata from invokeai.backend.model_manager.search import ModelSearch -from invokeai.backend.model_manager.taxonomy import ModelRepoVariant, ModelSourceType +from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelRepoVariant, ModelSourceType from invokeai.backend.model_manager.util.lora_metadata_extractor import apply_lora_metadata from invokeai.backend.util import InvokeAILogger from invokeai.backend.util.catch_sigint import catch_sigint @@ -451,6 +457,9 @@ def import_model(self, source: ModelSource, config: Optional[ModelRecordChanges] install_job = self._import_from_hf(source, config) elif isinstance(source, URLModelSource): install_job = self._import_from_url(source, config) + elif isinstance(source, ExternalModelSource): + install_job = self._import_external_model(source, config) + self._put_in_queue(install_job) else: raise ValueError(f"Unsupported model source: '{type(source)}'") @@ -748,7 +757,13 @@ def _guess_source(self, source: str) -> ModelSource: source_obj: Optional[StringLikeSource] = None source_stripped = source.strip('"') - if Path(source_stripped).exists(): # A local file or directory + if source_stripped.startswith("external://"): + external_id = source_stripped.removeprefix("external://") + provider_id, _, provider_model_id = external_id.partition("/") + if not provider_id or not provider_model_id: + raise ValueError(f"Invalid external model source: '{source_stripped}'") + source_obj = ExternalModelSource(provider_id=provider_id, provider_model_id=provider_model_id) + elif Path(source_stripped).exists(): # A local file or directory source_obj = LocalModelSource(path=Path(source_stripped)) elif match := re.match(hf_repoid_re, source): source_obj = HFModelSource( @@ -840,6 +855,9 @@ def _install_next_item(self) -> None: self._logger.info(f"Installer thread {threading.get_ident()} exiting") def _register_or_install(self, job: ModelInstallJob) -> None: + if isinstance(job.source, ExternalModelSource): + self._register_external_model(job) + return # local jobs will be in waiting state, remote jobs will be downloading state job.total_bytes = self._stat_size(job.local_path) job.bytes = job.total_bytes @@ -860,6 +878,41 @@ def _register_or_install(self, job: ModelInstallJob) -> None: job.config_out = self.record_store.get_model(key) self._signal_job_completed(job) + def _register_external_model(self, job: ModelInstallJob) -> None: + job.total_bytes = 0 + job.bytes = 0 + self._signal_job_running(job) + job.config_in.source = str(job.source) + job.config_in.source_type = MODEL_SOURCE_TO_TYPE_MAP[job.source.__class__] + + provider_id = job.source.provider_id + provider_model_id = job.source.provider_model_id + capabilities = job.config_in.capabilities or ExternalModelCapabilities() + default_settings = ( + job.config_in.default_settings + if isinstance(job.config_in.default_settings, ExternalApiModelDefaultSettings) + else None + ) + name = job.config_in.name or f"{provider_id} {provider_model_id}" + + config = ExternalApiModelConfig( + key=job.config_in.key or slugify(f"{provider_id}-{provider_model_id}"), + name=name, + description=job.config_in.description, + provider_id=provider_id, + provider_model_id=provider_model_id, + capabilities=capabilities, + default_settings=default_settings, + source=str(job.source), + source_type=MODEL_SOURCE_TO_TYPE_MAP[job.source.__class__], + path="", + hash="", + file_size=0, + ) + self.record_store.add_model(config) + job.config_out = self.record_store.get_model(config.key) + self._signal_job_completed(job) + def _set_error(self, install_job: ModelInstallJob, excp: Exception) -> None: multifile_download_job = install_job._multifile_job if multifile_download_job and any( @@ -895,6 +948,8 @@ def _scan_for_missing_models(self) -> list[AnyModelConfig]: """Scan the models directory for missing models and return a list of them.""" missing_models: list[AnyModelConfig] = [] for model_config in self.record_store.all_models(): + if model_config.base == BaseModelType.External or model_config.format == ModelFormat.ExternalApi: + continue if not (self.app_config.models_path / model_config.path).resolve().exists(): missing_models.append(model_config) return missing_models @@ -1036,6 +1091,19 @@ def _import_from_url( remote_files=remote_files, ) + def _import_external_model( + self, + source: ExternalModelSource, + config: Optional[ModelRecordChanges] = None, + ) -> ModelInstallJob: + return ModelInstallJob( + id=self._next_id(), + source=source, + config_in=config or ModelRecordChanges(), + local_path=self._app_config.models_path, + inplace=True, + ) + def _import_remote_model( self, source: HFModelSource | URLModelSource, diff --git a/invokeai/app/services/model_records/model_records_base.py b/invokeai/app/services/model_records/model_records_base.py index 96e12d3b0a3..318ebb000e6 100644 --- a/invokeai/app/services/model_records/model_records_base.py +++ b/invokeai/app/services/model_records/model_records_base.py @@ -13,6 +13,10 @@ from invokeai.app.services.shared.pagination import PaginatedResults from invokeai.app.util.model_exclude_null import BaseModelExcludeNull from invokeai.backend.model_manager.configs.controlnet import ControlAdapterDefaultSettings +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelDefaultSettings, + ExternalModelCapabilities, +) from invokeai.backend.model_manager.configs.factory import AnyModelConfig from invokeai.backend.model_manager.configs.lora import LoraModelDefaultSettings from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings @@ -86,8 +90,19 @@ class ModelRecordChanges(BaseModelExcludeNull): file_size: Optional[int] = Field(description="Size of model file", default=None) format: Optional[str] = Field(description="format of model file", default=None) trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None) - default_settings: Optional[MainModelDefaultSettings | LoraModelDefaultSettings | ControlAdapterDefaultSettings] = ( - Field(description="Default settings for this model", default=None) + default_settings: Optional[ + MainModelDefaultSettings + | LoraModelDefaultSettings + | ControlAdapterDefaultSettings + | ExternalApiModelDefaultSettings + ] = Field(description="Default settings for this model", default=None) + + # External API model changes + provider_id: Optional[str] = Field(description="External provider identifier", default=None) + provider_model_id: Optional[str] = Field(description="External provider model identifier", default=None) + capabilities: Optional[ExternalModelCapabilities] = Field( + description="External model capabilities", + default=None, ) cpu_only: Optional[bool] = Field(description="Whether this model should run on CPU only", default=None) diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py index 67e3c99f1ad..e38766d5ba2 100644 --- a/invokeai/app/services/shared/invocation_context.py +++ b/invokeai/app/services/shared/invocation_context.py @@ -388,6 +388,8 @@ def load( submodel_type = submodel_type or identifier.submodel_type model = self._services.model_manager.store.get_model(identifier.key) + self._raise_if_external(model) + message = f"Loading model {model.name}" if submodel_type: message += f" ({submodel_type.value})" @@ -417,12 +419,18 @@ def load_by_attrs( if len(configs) > 1: raise ValueError(f"More than one model found with name {name}, base {base}, and type {type}") + self._raise_if_external(configs[0]) message = f"Loading model {name}" if submodel_type: message += f" ({submodel_type.value})" self._util.signal_progress(message) return self._services.model_manager.load.load_model(configs[0], submodel_type) + @staticmethod + def _raise_if_external(model: AnyModelConfig) -> None: + if model.base == BaseModelType.External or model.format == ModelFormat.ExternalApi: + raise ValueError("External API models cannot be loaded from disk") + def get_config(self, identifier: Union[str, "ModelIdentifierField"]) -> AnyModelConfig: """Get a model's config. diff --git a/invokeai/backend/model_manager/configs/external_api.py b/invokeai/backend/model_manager/configs/external_api.py index e69de29bb2d..f57b4404e00 100644 --- a/invokeai/backend/model_manager/configs/external_api.py +++ b/invokeai/backend/model_manager/configs/external_api.py @@ -0,0 +1,80 @@ +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from invokeai.backend.model_manager.configs.base import Config_Base +from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError +from invokeai.backend.model_manager.model_on_disk import ModelOnDisk +from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelSourceType, ModelType + +ExternalGenerationMode = Literal["txt2img", "img2img", "inpaint"] +ExternalMaskFormat = Literal["alpha", "binary", "none"] + + +class ExternalImageSize(BaseModel): + width: int = Field(gt=0) + height: int = Field(gt=0) + + model_config = ConfigDict(extra="forbid") + + +class ExternalModelCapabilities(BaseModel): + modes: list[ExternalGenerationMode] = Field(default_factory=lambda: ["txt2img"]) + supports_reference_images: bool = Field(default=False) + supports_negative_prompt: bool = Field(default=True) + supports_seed: bool = Field(default=False) + supports_guidance: bool = Field(default=False) + max_images_per_request: int | None = Field(default=None, gt=0) + max_image_size: ExternalImageSize | None = Field(default=None) + allowed_aspect_ratios: list[str] | None = Field(default=None) + aspect_ratio_sizes: dict[str, ExternalImageSize] | None = Field(default=None) + max_reference_images: int | None = Field(default=None, gt=0) + mask_format: ExternalMaskFormat = Field(default="none") + input_image_required_for: list[ExternalGenerationMode] | None = Field(default=None) + + model_config = ConfigDict(extra="forbid") + + +class ExternalApiModelDefaultSettings(BaseModel): + width: int | None = Field(default=None, gt=0) + height: int | None = Field(default=None, gt=0) + steps: int | None = Field(default=None, gt=0) + guidance: float | None = Field(default=None, gt=0) + num_images: int | None = Field(default=None, gt=0) + + model_config = ConfigDict(extra="forbid") + + +class ExternalApiModelConfig(Config_Base): + base: Literal[BaseModelType.External] = Field(default=BaseModelType.External) + type: Literal[ModelType.ExternalImageGenerator] = Field(default=ModelType.ExternalImageGenerator) + format: Literal[ModelFormat.ExternalApi] = Field(default=ModelFormat.ExternalApi) + + provider_id: str = Field(min_length=1, description="External provider ID") + provider_model_id: str = Field(min_length=1, description="Provider-specific model ID") + capabilities: ExternalModelCapabilities = Field(description="Provider capability matrix") + default_settings: ExternalApiModelDefaultSettings | None = Field(default=None) + tags: list[str] | None = Field(default=None) + is_default: bool = Field(default=False) + + source_type: ModelSourceType = Field(default=ModelSourceType.Url) + path: str = Field(default="") + source: str = Field(default="") + hash: str = Field(default="") + file_size: int = Field(default=0, ge=0) + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def _populate_external_fields(self) -> "ExternalApiModelConfig": + if not self.path: + self.path = f"external://{self.provider_id}/{self.provider_model_id}" + if not self.source: + self.source = self.path + if not self.hash: + self.hash = f"external:{self.provider_id}:{self.provider_model_id}" + return self + + @classmethod + def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, object]) -> Self: + raise NotAMatchError("external API models are not probed from disk") diff --git a/invokeai/backend/model_manager/configs/factory.py b/invokeai/backend/model_manager/configs/factory.py index 7702d4a5d9b..81464a1a971 100644 --- a/invokeai/backend/model_manager/configs/factory.py +++ b/invokeai/backend/model_manager/configs/factory.py @@ -26,6 +26,7 @@ ControlNet_Diffusers_SD2_Config, ControlNet_Diffusers_SDXL_Config, ) +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig from invokeai.backend.model_manager.configs.flux_redux import FLUXRedux_Checkpoint_Config from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError from invokeai.backend.model_manager.configs.ip_adapter import ( @@ -256,6 +257,7 @@ Annotated[SigLIP_Diffusers_Config, SigLIP_Diffusers_Config.get_tag()], Annotated[FLUXRedux_Checkpoint_Config, FLUXRedux_Checkpoint_Config.get_tag()], Annotated[LlavaOnevision_Diffusers_Config, LlavaOnevision_Diffusers_Config.get_tag()], + Annotated[ExternalApiModelConfig, ExternalApiModelConfig.get_tag()], # Unknown model (fallback) Annotated[Unknown_Config, Unknown_Config.get_tag()], ], diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py index ef7cd80cd29..59d7ceba205 100644 --- a/invokeai/backend/model_manager/starter_models.py +++ b/invokeai/backend/model_manager/starter_models.py @@ -2,6 +2,11 @@ from pydantic import BaseModel +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, +) from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType @@ -13,6 +18,8 @@ class StarterModelWithoutDependencies(BaseModel): type: ModelType format: Optional[ModelFormat] = None is_installed: bool = False + capabilities: ExternalModelCapabilities | None = None + default_settings: ExternalApiModelDefaultSettings | None = None # allows us to track what models a user has installed across name changes within starter models # if you update a starter model name, please add the old one to this list for that starter model previous_names: list[str] = [] @@ -862,6 +869,108 @@ class StarterModelBundle(BaseModel): ) # endregion +# region External API +gemini_flash_image = StarterModel( + name="Gemini 2.5 Flash Image", + base=BaseModelType.External, + source="external://gemini/gemini-2.5-flash-image", + description="Google Gemini 2.5 Flash image generation model (external API).", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img", "inpaint"], + supports_negative_prompt=True, + supports_seed=True, + supports_guidance=True, + supports_reference_images=True, + max_images_per_request=1, + allowed_aspect_ratios=[ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", + ], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "2:3": ExternalImageSize(width=832, height=1248), + "3:2": ExternalImageSize(width=1248, height=832), + "3:4": ExternalImageSize(width=864, height=1184), + "4:3": ExternalImageSize(width=1184, height=864), + "4:5": ExternalImageSize(width=896, height=1152), + "5:4": ExternalImageSize(width=1152, height=896), + "9:16": ExternalImageSize(width=768, height=1344), + "16:9": ExternalImageSize(width=1344, height=768), + "21:9": ExternalImageSize(width=1536, height=672), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), +) +gemini_pro_image_preview = StarterModel( + name="Gemini 3 Pro Image Preview", + base=BaseModelType.External, + source="external://gemini/gemini-3-pro-image-preview", + description="Google Gemini 3 Pro image generation preview model (external API).", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img", "inpaint"], + supports_negative_prompt=True, + supports_seed=True, + supports_guidance=True, + supports_reference_images=True, + max_images_per_request=1, + allowed_aspect_ratios=[ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", + ], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "2:3": ExternalImageSize(width=832, height=1248), + "3:2": ExternalImageSize(width=1248, height=832), + "3:4": ExternalImageSize(width=864, height=1184), + "4:3": ExternalImageSize(width=1184, height=864), + "4:5": ExternalImageSize(width=896, height=1152), + "5:4": ExternalImageSize(width=1152, height=896), + "9:16": ExternalImageSize(width=768, height=1344), + "16:9": ExternalImageSize(width=1344, height=768), + "21:9": ExternalImageSize(width=1536, height=672), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), +) +openai_gpt_image_1 = StarterModel( + name="ChatGPT Image", + base=BaseModelType.External, + source="external://openai/gpt-image-1", + description="OpenAI GPT-Image-1 image generation model (external API).", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img", "inpaint"], + supports_negative_prompt=True, + supports_seed=True, + supports_guidance=True, + supports_reference_images=False, + max_images_per_request=1, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), +) +# endregion + # List of starter models, displayed on the frontend. # The order/sort of this list is not changed by the frontend - set it how you want it here. STARTER_MODELS: list[StarterModel] = [ @@ -957,6 +1066,9 @@ class StarterModelBundle(BaseModel): z_image_qwen3_encoder_quantized, z_image_controlnet_union, z_image_controlnet_tile, + gemini_flash_image, + gemini_pro_image_preview, + openai_gpt_image_1, ] sd1_bundle: list[StarterModel] = [ diff --git a/invokeai/backend/model_manager/taxonomy.py b/invokeai/backend/model_manager/taxonomy.py index c002418a6bd..855a39441d9 100644 --- a/invokeai/backend/model_manager/taxonomy.py +++ b/invokeai/backend/model_manager/taxonomy.py @@ -52,6 +52,8 @@ class BaseModelType(str, Enum): """Indicates the model is associated with CogView 4 model architecture.""" ZImage = "z-image" """Indicates the model is associated with Z-Image model architecture, including Z-Image-Turbo.""" + External = "external" + """Indicates the model is hosted by an external provider.""" Unknown = "unknown" """Indicates the model's base architecture is unknown.""" @@ -76,6 +78,7 @@ class ModelType(str, Enum): SigLIP = "siglip" FluxRedux = "flux_redux" LlavaOnevision = "llava_onevision" + ExternalImageGenerator = "external_image_generator" Unknown = "unknown" @@ -170,6 +173,7 @@ class ModelFormat(str, Enum): BnbQuantizedLlmInt8b = "bnb_quantized_int8b" BnbQuantizednf4b = "bnb_quantized_nf4b" GGUFQuantized = "gguf_quantized" + ExternalApi = "external_api" Unknown = "unknown" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c28df6ee383..9c726b6c938 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -993,6 +993,22 @@ "fileSize": "File Size", "filterModels": "Filter models", "fluxRedux": "FLUX Redux", + "externalImageGenerator": "External Image Generator", + "externalProviders": "External Providers", + "externalSetupTitle": "External Providers Setup", + "externalSetupDescription": "Connect an API key to enable external image generation and optionally install curated external models.", + "externalInstallDefaults": "Auto-install starter models", + "externalProvidersUnavailable": "External providers are not available in this build.", + "externalSetupFooter": "External providers use remote APIs; usage may incur provider-side costs.", + "externalProviderCardDescription": "Configure {{providerId}} credentials for external image generation.", + "externalApiKey": "API Key", + "externalApiKeyPlaceholder": "Paste your API key", + "externalApiKeyPlaceholderSet": "API key configured", + "externalApiKeyHelper": "Stored in your InvokeAI config file.", + "externalBaseUrl": "Base URL (optional)", + "externalBaseUrlPlaceholder": "https://...", + "externalBaseUrlHelper": "Override the default API base URL if needed.", + "externalResetHelper": "Clear API key and base URL.", "height": "Height", "huggingFace": "HuggingFace", "huggingFacePlaceholder": "owner/model-name", @@ -1060,6 +1076,21 @@ "modelUpdated": "Model Updated", "modelUpdateFailed": "Model Update Failed", "name": "Name", + "externalProvider": "External Provider", + "externalCapabilities": "External Capabilities", + "externalDefaults": "External Defaults", + "providerId": "Provider ID", + "providerModelId": "Provider Model ID", + "supportedModes": "Supported Modes", + "supportsNegativePrompt": "Supports Negative Prompt", + "supportsReferenceImages": "Supports Reference Images", + "supportsSeed": "Supports Seed", + "supportsGuidance": "Supports Guidance", + "maxImagesPerRequest": "Max Images Per Request", + "maxReferenceImages": "Max Reference Images", + "maxImageWidth": "Max Image Width", + "maxImageHeight": "Max Image Height", + "numImages": "Num Images", "modelPickerFallbackNoModelsInstalled": "No models installed.", "modelPickerFallbackNoModelsInstalled2": "Visit the Model Manager to install models.", "noModelsInstalledDesc1": "Install models with the", @@ -1102,6 +1133,7 @@ "urlDescription": "Install models from a URL or local file path. Perfect for specific models you want to add.", "huggingFaceDescription": "Browse and install models directly from HuggingFace repositories.", "scanFolderDescription": "Scan a local folder to automatically detect and install models.", + "externalDescription": "Connect to Gemini or OpenAI to generate images with external APIs.", "recommendedModels": "Recommended Models", "exploreStarter": "Or browse all available starter models", "quickStart": "Quick Start Bundles", @@ -1575,7 +1607,11 @@ "intermediatesCleared_one": "Cleared {{count}} Intermediate", "intermediatesCleared_other": "Cleared {{count}} Intermediates", "intermediatesClearedFailed": "Problem Clearing Intermediates", - "reloadingIn": "Reloading in" + "reloadingIn": "Reloading in", + "externalProviders": "External Providers", + "externalProviderConfigured": "Configured", + "externalProviderNotConfigured": "API Key Required", + "externalProviderNotConfiguredHint": "Add your API key in Model Manager or the server config to enable this provider." }, "toast": { "addedToBoard": "Added to board {{name}}'s assets", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx index 6b8da8dc4da..c8333600c56 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx @@ -72,10 +72,17 @@ export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWi onAccept: (item, imageDTO) => { const bboxRect = selectBboxRect(store.getState()); const { x, y } = bboxRect; - const imageObject = imageDTOToImageObject(imageDTO); + const imageObject = imageDTOToImageObject(imageDTO, { usePixelBbox: false }); + const scale = Math.min(bboxRect.width / imageDTO.width, bboxRect.height / imageDTO.height); + const scaledWidth = Math.round(imageDTO.width * scale); + const scaledHeight = Math.round(imageDTO.height * scale); + const position = { + x: x + Math.round((bboxRect.width - scaledWidth) / 2), + y: y + Math.round((bboxRect.height - scaledHeight) / 2), + }; const selectedEntityIdentifier = selectSelectedEntityIdentifier(store.getState()); const overrides: Partial = { - position: { x, y }, + position, objects: [imageObject], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts index f16b9023164..9268fc7570f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts @@ -181,6 +181,33 @@ describe('StagingAreaApi Utility Functions', () => { expect(result).toBe('first-image.png'); }); + it('should return first image from image collections', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + canvas_output: ['output-node-id'], + }, + results: { + 'output-node-id': { + images: [{ image_name: 'first.png' }, { image_name: 'second.png' }], + }, + }, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe('first.png'); + }); + it('should handle empty session mapping', () => { const queueItem: S['SessionQueueItem'] = { item_id: 1, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts index fe98408df58..1fe461e9993 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts @@ -1,4 +1,4 @@ -import { isImageField } from 'features/nodes/types/common'; +import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; import type { S } from 'services/api/types'; import { formatProgressMessage } from 'services/events/stores'; @@ -29,6 +29,9 @@ export const getOutputImageName = (item: S['SessionQueueItem']) => { if (isImageField(value)) { return value.image_name; } + if (isImageFieldCollection(value)) { + return value[0]?.image_name ?? null; + } } return null; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts new file mode 100644 index 00000000000..03de58908f0 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts @@ -0,0 +1,61 @@ +import { zModelIdentifierField } from 'features/nodes/types/common'; +import type { + ExternalApiModelConfig, + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, +} from 'services/api/types'; +import { describe, expect, it } from 'vitest'; + +import { selectModelSupportsNegativePrompt, selectModelSupportsRefImages } from './paramsSlice'; + +const createExternalConfig = (capabilities: ExternalModelCapabilities): ExternalApiModelConfig => { + const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 }; + const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024, steps: 30 }; + + return { + key: 'external-test', + hash: 'external:openai:gpt-image-1', + path: 'external://openai/gpt-image-1', + file_size: 0, + name: 'External Test', + description: null, + source: 'external://openai/gpt-image-1', + source_type: 'url', + source_api_response: null, + cover_image: null, + base: 'external', + type: 'external_image_generator', + format: 'external_api', + provider_id: 'openai', + provider_model_id: 'gpt-image-1', + capabilities: { ...capabilities, max_image_size: maxImageSize }, + default_settings: defaultSettings, + tags: ['external'], + is_default: false, + }; +}; + +describe('paramsSlice selectors for external models', () => { + it('uses external capabilities for negative prompt support', () => { + const config = createExternalConfig({ + modes: ['txt2img'], + supports_negative_prompt: true, + supports_reference_images: false, + }); + const model = zModelIdentifierField.parse(config); + + expect(selectModelSupportsNegativePrompt.resultFunc(model, config)).toBe(true); + }); + + it('uses external capabilities for ref image support', () => { + const config = createExternalConfig({ + modes: ['txt2img'], + supports_negative_prompt: false, + supports_reference_images: false, + }); + const model = zModelIdentifierField.parse(config); + + expect(selectModelSupportsRefImages.resultFunc(model, config)).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 8dcd93cc5de..41afb2ac19c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -43,7 +43,7 @@ import type { } from 'features/parameters/types/parameterSchemas'; import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; -import { isNonRefinerMainModelConfig } from 'services/api/types'; +import { isExternalApiModelConfig, isNonRefinerMainModelConfig } from 'services/api/types'; import { assert } from 'tsafe'; const slice = createSlice({ @@ -638,15 +638,42 @@ export const selectOptimizedDenoisingEnabled = createParamsSelector((params) => export const selectPositivePrompt = createParamsSelector((params) => params.positivePrompt); export const selectNegativePrompt = createParamsSelector((params) => params.negativePrompt); export const selectNegativePromptWithFallback = createParamsSelector((params) => params.negativePrompt ?? ''); +export const selectModelConfig = createSelector( + selectModelConfigsQuery, + selectParamsSlice, + (modelConfigs, { model }) => { + if (!modelConfigs.data) { + return null; + } + if (!model) { + return null; + } + return modelConfigsAdapterSelectors.selectById(modelConfigs.data, model.key) ?? null; + } +); export const selectHasNegativePrompt = createParamsSelector((params) => params.negativePrompt !== null); export const selectModelSupportsNegativePrompt = createSelector( selectModel, - (model) => !!model && SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS.includes(model.base) -); -export const selectModelSupportsRefImages = createSelector( - selectModel, - (model) => !!model && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base) + selectModelConfig, + (model, modelConfig) => { + if (!model) { + return false; + } + if (modelConfig && isExternalApiModelConfig(modelConfig)) { + return modelConfig.capabilities.supports_negative_prompt ?? false; + } + return SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS.includes(model.base); + } ); +export const selectModelSupportsRefImages = createSelector(selectModel, selectModelConfig, (model, modelConfig) => { + if (!model) { + return false; + } + if (modelConfig && isExternalApiModelConfig(modelConfig)) { + return modelConfig.capabilities.supports_reference_images ?? false; + } + return SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base); +}); export const selectModelSupportsOptimizedDenoising = createSelector( selectModel, (model) => !!model && SUPPORTS_OPTIMIZED_DENOISING_BASE_MODELS.includes(model.base) @@ -693,24 +720,23 @@ export const selectHeight = createParamsSelector((params) => params.dimensions.h export const selectAspectRatioID = createParamsSelector((params) => params.dimensions.aspectRatio.id); export const selectAspectRatioValue = createParamsSelector((params) => params.dimensions.aspectRatio.value); export const selectAspectRatioIsLocked = createParamsSelector((params) => params.dimensions.aspectRatio.isLocked); +export const selectAllowedAspectRatioIDs = createSelector(selectModelConfig, (modelConfig) => { + if (!modelConfig || !isExternalApiModelConfig(modelConfig)) { + return null; + } + const allowed = modelConfig.capabilities.allowed_aspect_ratios; + return allowed?.length ? allowed : null; +}); -export const selectMainModelConfig = createSelector( - selectModelConfigsQuery, - selectParamsSlice, - (modelConfigs, { model }) => { - if (!modelConfigs.data) { - return null; - } - if (!model) { - return null; - } - const modelConfig = modelConfigsAdapterSelectors.selectById(modelConfigs.data, model.key); - if (!modelConfig) { - return null; - } - if (!isNonRefinerMainModelConfig(modelConfig)) { - return null; - } +export const selectMainModelConfig = createSelector(selectModelConfig, (modelConfig) => { + if (!modelConfig) { + return null; + } + if (isExternalApiModelConfig(modelConfig)) { return modelConfig; } -); + if (!isNonRefinerMainModelConfig(modelConfig)) { + return null; + } + return modelConfig; +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts index 3406e9e7ee6..e431c10558f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts @@ -6,7 +6,7 @@ import type { RefImageState, } from 'features/controlLayers/store/types'; import type { ModelIdentifierField } from 'features/nodes/types/common'; -import type { AnyModelConfig, MainModelConfig } from 'services/api/types'; +import type { AnyModelConfig, MainOrExternalModelConfig } from 'services/api/types'; const WARNINGS = { UNSUPPORTED_MODEL: 'controlLayers.warnings.unsupportedModel', @@ -28,7 +28,7 @@ type WarningTKey = (typeof WARNINGS)[keyof typeof WARNINGS]; export const getRegionalGuidanceWarnings = ( entity: CanvasRegionalGuidanceState, - model: MainModelConfig | null | undefined + model: MainOrExternalModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; @@ -112,7 +112,7 @@ export const areBasesCompatibleForRefImage = ( export const getGlobalReferenceImageWarnings = ( entity: RefImageState, - model: MainModelConfig | null | undefined + model: MainOrExternalModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; @@ -147,7 +147,7 @@ export const getGlobalReferenceImageWarnings = ( export const getControlLayerWarnings = ( entity: CanvasControlLayerState, - model: MainModelConfig | null | undefined + model: MainOrExternalModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; @@ -181,7 +181,7 @@ export const getControlLayerWarnings = ( export const getRasterLayerWarnings = ( _entity: CanvasRasterLayerState, - _model: MainModelConfig | null | undefined + _model: MainOrExternalModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; @@ -192,7 +192,7 @@ export const getRasterLayerWarnings = ( export const getInpaintMaskWarnings = ( _entity: CanvasInpaintMaskState, - _model: MainModelConfig | null | undefined + _model: MainOrExternalModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/models.ts b/invokeai/frontend/web/src/features/modelManagerV2/models.ts index 7b5a08adfe2..99cd2e8a573 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/models.ts +++ b/invokeai/frontend/web/src/features/modelManagerV2/models.ts @@ -1,10 +1,11 @@ import type { AnyModelVariant, BaseModelType, ModelFormat, ModelType } from 'features/nodes/types/common'; -import type { AnyModelConfig } from 'services/api/types'; import { + type AnyModelConfig, isCLIPEmbedModelConfig, isCLIPVisionModelConfig, isControlLoRAModelConfig, isControlNetModelConfig, + isExternalApiModelConfig, isFluxReduxModelConfig, isIPAdapterModelConfig, isLLaVAModelConfig, @@ -121,6 +122,11 @@ export const MODEL_CATEGORIES: Record = { i18nKey: 'modelManager.llavaOnevision', filter: isLLaVAModelConfig, }, + external_image_generator: { + category: 'external_image_generator', + i18nKey: 'modelManager.externalImageGenerator', + filter: isExternalApiModelConfig, + }, }; export const MODEL_CATEGORIES_AS_LIST = objectEntries(MODEL_CATEGORIES).map(([category, { i18nKey, filter }]) => ({ @@ -143,6 +149,7 @@ export const MODEL_BASE_TO_COLOR: Record = { flux2: 'gold', cogview4: 'red', 'z-image': 'cyan', + external: 'orange', unknown: 'red', }; @@ -167,6 +174,7 @@ export const MODEL_TYPE_TO_LONG_NAME: Record = { clip_embed: 'CLIP Embed', siglip: 'SigLIP', flux_redux: 'FLUX Redux', + external_image_generator: 'External Image Generator', unknown: 'Unknown', }; @@ -184,6 +192,7 @@ export const MODEL_BASE_TO_LONG_NAME: Record = { flux2: 'FLUX.2', cogview4: 'CogView4', 'z-image': 'Z-Image', + external: 'External', unknown: 'Unknown', }; @@ -201,6 +210,7 @@ export const MODEL_BASE_TO_SHORT_NAME: Record = { flux2: 'FLUX.2', cogview4: 'CogView4', 'z-image': 'Z-Image', + external: 'External', unknown: 'Unknown', }; @@ -228,6 +238,7 @@ export const MODEL_FORMAT_TO_LONG_NAME: Record = { checkpoint: 'Checkpoint', lycoris: 'LyCORIS', onnx: 'ONNX', + external_api: 'External API', olive: 'Olive', embedding_file: 'Embedding (file)', embedding_folder: 'Embedding (folder)', diff --git a/invokeai/frontend/web/src/features/modelManagerV2/store/installModelsStore.ts b/invokeai/frontend/web/src/features/modelManagerV2/store/installModelsStore.ts index b99a1212fec..79b7bfe31a7 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/store/installModelsStore.ts +++ b/invokeai/frontend/web/src/features/modelManagerV2/store/installModelsStore.ts @@ -1,13 +1,14 @@ import { atom } from 'nanostores'; -type InstallModelsTabName = 'launchpad' | 'urlOrLocal' | 'huggingface' | 'scanFolder' | 'starterModels'; +type InstallModelsTabName = 'launchpad' | 'urlOrLocal' | 'huggingface' | 'external' | 'scanFolder' | 'starterModels'; const TAB_TO_INDEX_MAP: Record = { launchpad: 0, urlOrLocal: 1, huggingface: 2, - scanFolder: 3, - starterModels: 4, + external: 3, + scanFolder: 4, + starterModels: 5, }; export const setInstallModelsTabByName = (tab: InstallModelsTabName) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx new file mode 100644 index 00000000000..26820cb0e29 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx @@ -0,0 +1,281 @@ +import { + Badge, + Button, + Card, + Flex, + FormControl, + FormHelperText, + FormLabel, + Heading, + Input, + Switch, + Text, + Tooltip, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { useBuildModelInstallArg } from 'features/modelManagerV2/hooks/useBuildModelsToInstall'; +import { useInstallModel } from 'features/modelManagerV2/hooks/useInstallModel'; +import { $installModelsTabIndex } from 'features/modelManagerV2/store/installModelsStore'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCheckBold, PiWarningBold } from 'react-icons/pi'; +import { + useGetExternalProviderConfigsQuery, + useResetExternalProviderConfigMutation, + useSetExternalProviderConfigMutation, +} from 'services/api/endpoints/appInfo'; +import { useGetStarterModelsQuery } from 'services/api/endpoints/models'; +import type { ExternalProviderConfig, StarterModel } from 'services/api/types'; + +const PROVIDER_SORT_ORDER = ['gemini', 'openai']; + +type ProviderCardProps = { + provider: ExternalProviderConfig; + onInstallModels: (providerId: string) => void; +}; + +type UpdatePayload = { + provider_id: string; + api_key?: string; + base_url?: string; +}; + +export const ExternalProvidersForm = memo(() => { + const { t } = useTranslation(); + const { data, isLoading } = useGetExternalProviderConfigsQuery(); + const { data: starterModels } = useGetStarterModelsQuery(); + const [installModel] = useInstallModel(); + const { getIsInstalled, buildModelInstallArg } = useBuildModelInstallArg(); + const [installDefaults, setInstallDefaults] = useState(true); + const tabIndex = useStore($installModelsTabIndex); + + const toggleInstallDefaults = useCallback((event: ChangeEvent) => { + setInstallDefaults(event.target.checked); + }, []); + + const externalModelsByProvider = useMemo(() => { + const groups = new Map(); + for (const model of starterModels?.starter_models ?? []) { + if (!model.source.startsWith('external://')) { + continue; + } + const providerId = model.source.replace('external://', '').split('/')[0]; + if (!providerId) { + continue; + } + const models = groups.get(providerId) ?? []; + models.push(model); + groups.set(providerId, models); + } + + for (const [providerId, models] of groups.entries()) { + models.sort((a, b) => a.name.localeCompare(b.name)); + groups.set(providerId, models); + } + + return groups; + }, [starterModels]); + + const handleInstallProviderModels = useCallback( + (providerId: string) => { + if (!installDefaults) { + return; + } + const models = externalModelsByProvider.get(providerId); + if (!models?.length) { + return; + } + const modelsToInstall = models.filter((model) => !getIsInstalled(model)).map(buildModelInstallArg); + modelsToInstall.forEach((model) => installModel(model)); + }, + [buildModelInstallArg, externalModelsByProvider, getIsInstalled, installDefaults, installModel] + ); + + const sortedProviders = useMemo(() => { + if (!data) { + return []; + } + return [...data].sort((a, b) => { + const aIndex = PROVIDER_SORT_ORDER.indexOf(a.provider_id); + const bIndex = PROVIDER_SORT_ORDER.indexOf(b.provider_id); + if (aIndex === -1 && bIndex === -1) { + return a.provider_id.localeCompare(b.provider_id); + } + if (aIndex === -1) { + return 1; + } + if (bIndex === -1) { + return -1; + } + return aIndex - bIndex; + }); + }, [data]); + + return ( + + + + {t('modelManager.externalSetupTitle')} + {t('modelManager.externalSetupDescription')} + + + {t('modelManager.externalInstallDefaults')} + + + + + + {isLoading && {t('common.loading')}} + {!isLoading && sortedProviders.length === 0 && ( + {t('modelManager.externalProvidersUnavailable')} + )} + {sortedProviders.map((provider) => ( + + ))} + + + {tabIndex === 3 && ( + + {t('modelManager.externalSetupFooter')} + + )} + + ); +}); + +ExternalProvidersForm.displayName = 'ExternalProvidersForm'; + +const ProviderCard = memo(({ provider, onInstallModels }: ProviderCardProps) => { + const { t } = useTranslation(); + const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(provider.base_url ?? ''); + const [saveConfig, { isLoading }] = useSetExternalProviderConfigMutation(); + const [resetConfig, { isLoading: isResetting }] = useResetExternalProviderConfigMutation(); + + useEffect(() => { + setBaseUrl(provider.base_url ?? ''); + }, [provider.base_url]); + + const handleSave = useCallback(() => { + const trimmedApiKey = apiKey.trim(); + const trimmedBaseUrl = baseUrl.trim(); + const updatePayload: UpdatePayload = { + provider_id: provider.provider_id, + }; + if (trimmedApiKey) { + updatePayload.api_key = trimmedApiKey; + } + if (trimmedBaseUrl !== (provider.base_url ?? '')) { + updatePayload.base_url = trimmedBaseUrl; + } + + if (!updatePayload.api_key && updatePayload.base_url === undefined) { + return; + } + + saveConfig(updatePayload) + .unwrap() + .then((result) => { + if (result.api_key_configured) { + setApiKey(''); + onInstallModels(provider.provider_id); + } + if (result.base_url !== undefined) { + setBaseUrl(result.base_url ?? ''); + } + }); + }, [apiKey, baseUrl, onInstallModels, provider.base_url, provider.provider_id, saveConfig]); + + const handleReset = useCallback(() => { + resetConfig(provider.provider_id) + .unwrap() + .then((result) => { + setApiKey(''); + setBaseUrl(result.base_url ?? ''); + }); + }, [provider.provider_id, resetConfig]); + + const handleApiKeyChange = useCallback((event: ChangeEvent) => { + setApiKey(event.target.value); + }, []); + + const handleBaseUrlChange = useCallback((event: ChangeEvent) => { + setBaseUrl(event.target.value); + }, []); + + const statusBadge = provider.api_key_configured ? ( + + + {t('settings.externalProviderConfigured')} + + ) : ( + + + {t('settings.externalProviderNotConfigured')} + + ); + + return ( + + + + + {provider.provider_id} + + + {t('modelManager.externalProviderCardDescription', { providerId: provider.provider_id })} + + + {statusBadge} + + + + {t('modelManager.externalApiKey')} + + {t('modelManager.externalApiKeyHelper')} + + + {t('modelManager.externalBaseUrl')} + + {t('modelManager.externalBaseUrlHelper')} + + + + + + + + + + ); +}); + +ProviderCard.displayName = 'ProviderCard'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/LaunchpadForm/LaunchpadForm.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/LaunchpadForm/LaunchpadForm.tsx index fc99bcec7bf..591c61a4b23 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/LaunchpadForm/LaunchpadForm.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/LaunchpadForm/LaunchpadForm.tsx @@ -6,7 +6,7 @@ import { StarterBundleButton } from 'features/modelManagerV2/subpanels/AddModelP import { StarterBundleTooltipContentCompact } from 'features/modelManagerV2/subpanels/AddModelPanel/StarterModels/StarterBundleTooltipContentCompact'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiFolderOpenBold, PiLinkBold, PiStarBold } from 'react-icons/pi'; +import { PiFolderOpenBold, PiLinkBold, PiPlugBold, PiStarBold } from 'react-icons/pi'; import { SiHuggingface } from 'react-icons/si'; import { useGetStarterModelsQuery } from 'services/api/endpoints/models'; @@ -28,6 +28,10 @@ export const LaunchpadForm = memo(() => { setInstallModelsTabByName('scanFolder'); }, []); + const navigateToExternalTab = useCallback(() => { + setInstallModelsTabByName('external'); + }, []); + const navigateToStarterModelsTab = useCallback(() => { setInstallModelsTabByName('starterModels'); }, []); @@ -63,6 +67,12 @@ export const LaunchpadForm = memo(() => { title={t('modelManager.scanFolder')} description={t('modelManager.launchpad.scanFolderDescription')} /> + {/* Recommended Section */} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/InstallModels.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/InstallModels.tsx index 9039c0f85f4..5bc4c9713fc 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/InstallModels.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/InstallModels.tsx @@ -2,18 +2,18 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $installModelsTabIndex } from 'features/modelManagerV2/store/installModelsStore'; +import { ExternalProvidersForm } from 'features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm'; +import { HuggingFaceForm } from 'features/modelManagerV2/subpanels/AddModelPanel/HuggingFaceFolder/HuggingFaceForm'; +import { InstallModelForm } from 'features/modelManagerV2/subpanels/AddModelPanel/InstallModelForm'; +import { LaunchpadForm } from 'features/modelManagerV2/subpanels/AddModelPanel/LaunchpadForm/LaunchpadForm'; +import { ModelInstallQueue } from 'features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue'; +import { ScanModelsForm } from 'features/modelManagerV2/subpanels/AddModelPanel/ScanFolder/ScanFolderForm'; import { StarterModelsForm } from 'features/modelManagerV2/subpanels/AddModelPanel/StarterModels/StarterModelsForm'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCubeBold, PiFolderOpenBold, PiLinkSimpleBold, PiShootingStarBold } from 'react-icons/pi'; +import { PiCubeBold, PiFolderOpenBold, PiLinkSimpleBold, PiPlugBold, PiShootingStarBold } from 'react-icons/pi'; import { SiHuggingface } from 'react-icons/si'; -import { HuggingFaceForm } from './AddModelPanel/HuggingFaceFolder/HuggingFaceForm'; -import { InstallModelForm } from './AddModelPanel/InstallModelForm'; -import { LaunchpadForm } from './AddModelPanel/LaunchpadForm/LaunchpadForm'; -import { ModelInstallQueue } from './AddModelPanel/ModelInstallQueue/ModelInstallQueue'; -import { ScanModelsForm } from './AddModelPanel/ScanFolder/ScanFolderForm'; - const installModelsTabSx: SystemStyleObject = { display: 'flex', gap: 2, @@ -61,6 +61,10 @@ export const InstallModels = memo(() => { {t('modelManager.huggingFace')} + + + {t('modelManager.externalProviders')} + {t('modelManager.scanFolder')} @@ -80,6 +84,9 @@ export const InstallModels = memo(() => { + + + diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx index 7d44ee54637..ff4dbe88fc8 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx @@ -19,6 +19,7 @@ const FORMAT_NAME_MAP: Record = { bnb_quantized_nf4b: 'quantized', gguf_quantized: 'gguf', omi: 'omi', + external_api: 'external_api', unknown: 'unknown', olive: 'olive', onnx: 'onnx', @@ -40,6 +41,7 @@ const FORMAT_COLOR_MAP: Record = { unknown: 'red', olive: 'base', onnx: 'base', + external_api: 'base', }; const ModelFormatBadge = ({ format }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/BaseModelSelect.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/BaseModelSelect.tsx index 8235d26efef..e4c8752e569 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/BaseModelSelect.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/BaseModelSelect.tsx @@ -5,7 +5,7 @@ import { MODEL_BASE_TO_LONG_NAME } from 'features/modelManagerV2/models'; import { useCallback, useMemo } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; -import type { UpdateModelArg } from 'services/api/endpoints/models'; +import type { UpdateModelBody } from 'services/api/types'; import { objectEntries } from 'tsafe'; const options: ComboboxOption[] = objectEntries(MODEL_BASE_TO_LONG_NAME).map(([value, label]) => ({ @@ -14,7 +14,7 @@ const options: ComboboxOption[] = objectEntries(MODEL_BASE_TO_LONG_NAME).map(([v })); type Props = { - control: Control; + control: Control; }; const BaseModelSelect = ({ control }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelFormatSelect.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelFormatSelect.tsx index 1057ab7784c..2bd3eb954e5 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelFormatSelect.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelFormatSelect.tsx @@ -5,7 +5,7 @@ import { MODEL_FORMAT_TO_LONG_NAME } from 'features/modelManagerV2/models'; import { useCallback, useMemo } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; -import type { UpdateModelArg } from 'services/api/endpoints/models'; +import type { UpdateModelBody } from 'services/api/types'; import { objectEntries } from 'tsafe'; const options: ComboboxOption[] = objectEntries(MODEL_FORMAT_TO_LONG_NAME).map(([value, label]) => ({ @@ -14,7 +14,7 @@ const options: ComboboxOption[] = objectEntries(MODEL_FORMAT_TO_LONG_NAME).map(( })); type Props = { - control: Control; + control: Control; }; const ModelFormatSelect = ({ control }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelTypeSelect.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelTypeSelect.tsx index 44b41f01518..b35ce7f96df 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelTypeSelect.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelTypeSelect.tsx @@ -5,7 +5,7 @@ import { MODEL_TYPE_TO_LONG_NAME } from 'features/modelManagerV2/models'; import { useCallback, useMemo } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; -import type { UpdateModelArg } from 'services/api/endpoints/models'; +import type { UpdateModelBody } from 'services/api/types'; import { objectEntries } from 'tsafe'; const options: ComboboxOption[] = objectEntries(MODEL_TYPE_TO_LONG_NAME).map(([value, label]) => ({ @@ -14,7 +14,7 @@ const options: ComboboxOption[] = objectEntries(MODEL_TYPE_TO_LONG_NAME).map(([v })); type Props = { - control: Control; + control: Control; }; const ModelTypeSelect = ({ control }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelVariantSelect.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelVariantSelect.tsx index 52eb2a4749d..d8e8c6a5b8a 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelVariantSelect.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelVariantSelect.tsx @@ -5,13 +5,13 @@ import { MODEL_VARIANT_TO_LONG_NAME } from 'features/modelManagerV2/models'; import { useCallback, useMemo } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; -import type { UpdateModelArg } from 'services/api/endpoints/models'; +import type { UpdateModelBody } from 'services/api/types'; import { objectEntries } from 'tsafe'; const options: ComboboxOption[] = objectEntries(MODEL_VARIANT_TO_LONG_NAME).map(([value, label]) => ({ label, value })); type Props = { - control: Control; + control: Control; }; const ModelVariantSelect = ({ control }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/PredictionTypeSelect.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/PredictionTypeSelect.tsx index dcef95b4243..593bc4c4136 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/PredictionTypeSelect.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/PredictionTypeSelect.tsx @@ -4,7 +4,7 @@ import { typedMemo } from 'common/util/typedMemo'; import { useCallback, useMemo } from 'react'; import type { Control } from 'react-hook-form'; import { useController } from 'react-hook-form'; -import type { UpdateModelArg } from 'services/api/endpoints/models'; +import type { UpdateModelBody } from 'services/api/types'; const options: ComboboxOption[] = [ { value: 'none', label: '-' }, @@ -14,7 +14,7 @@ const options: ComboboxOption[] = [ ]; type Props = { - control: Control; + control: Control; }; const PredictionTypeSelect = ({ control }: Props) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelEdit.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelEdit.tsx index d845eca3eec..7cde65bf072 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelEdit.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelEdit.tsx @@ -15,12 +15,17 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { setSelectedModelMode } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { ModelHeader } from 'features/modelManagerV2/subpanels/ModelPanel/ModelHeader'; import { toast } from 'features/toast/toast'; -import { memo, useCallback } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; +import { memo, useCallback, useMemo } from 'react'; +import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { PiCheckBold, PiXBold } from 'react-icons/pi'; import { type UpdateModelArg, useUpdateModelMutation } from 'services/api/endpoints/models'; -import type { AnyModelConfig } from 'services/api/types'; +import { + type AnyModelConfig, + type ExternalModelCapabilities, + isExternalApiModelConfig, + type UpdateModelBody, +} from 'services/api/types'; import BaseModelSelect from './Fields/BaseModelSelect'; import ModelFormatSelect from './Fields/ModelFormatSelect'; @@ -33,6 +38,8 @@ type Props = { modelConfig: AnyModelConfig; }; +type ModelEditFormValues = UpdateModelBody; + const stringFieldOptions = { validate: (value?: string | null) => (value && value.trim().length > 3) || 'Must be at least 3 characters', }; @@ -41,19 +48,54 @@ export const ModelEdit = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); const [updateModel, { isLoading: isSubmitting }] = useUpdateModelMutation(); const dispatch = useAppDispatch(); + const isExternal = useMemo(() => isExternalApiModelConfig(modelConfig), [modelConfig]); - const form = useForm({ + const form = useForm({ defaultValues: modelConfig, mode: 'onChange', }); - const onSubmit = useCallback>( + const externalModes = useWatch({ + control: form.control, + name: 'capabilities.modes', + }) as ExternalModelCapabilities['modes'] | undefined; + + const modeSet = useMemo(() => new Set(externalModes ?? []), [externalModes]); + + const toggleMode = useCallback( + (mode: ExternalModelCapabilities['modes'][number]) => { + const nextModes = modeSet.has(mode) + ? externalModes?.filter((value) => value !== mode) + : [...(externalModes ?? []), mode]; + form.setValue('capabilities.modes', nextModes ?? [], { shouldDirty: true, shouldValidate: true }); + }, + [externalModes, form, modeSet] + ); + + const handleToggleTxt2Img = useCallback(() => toggleMode('txt2img'), [toggleMode]); + const handleToggleImg2Img = useCallback(() => toggleMode('img2img'), [toggleMode]); + const handleToggleInpaint = useCallback(() => toggleMode('inpaint'), [toggleMode]); + + const parseOptionalNumber = useCallback((value: string | null | undefined) => { + if (value === null || value === undefined || value === '') { + return null; + } + if (typeof value !== 'string') { + return Number.isNaN(Number(value)) ? null : Number(value); + } + if (value.trim() === '') { + return null; + } + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; + }, []); + + const onSubmit = useCallback>( (values) => { const responseBody: UpdateModelArg = { key: modelConfig.key, body: values, }; - updateModel(responseBody) .unwrap() .then((payload) => { @@ -160,6 +202,144 @@ export const ModelEdit = memo(({ modelConfig }: Props) => { + {isExternal && ( + <> + + {t('modelManager.externalProvider')} + + + + {t('modelManager.providerId')} + + + + {t('modelManager.providerModelId')} + + + + + {t('modelManager.externalCapabilities')} + + + + {t('modelManager.supportedModes')} + + + txt2img + + + img2img + + + inpaint + + + + + {t('modelManager.supportsNegativePrompt')} + + + + {t('modelManager.supportsReferenceImages')} + + + + {t('modelManager.supportsSeed')} + + + + {t('modelManager.supportsGuidance')} + + + + {t('modelManager.maxImagesPerRequest')} + + + + {t('modelManager.maxReferenceImages')} + + + + {t('modelManager.maxImageWidth')} + + + + {t('modelManager.maxImageHeight')} + + + + + {t('modelManager.externalDefaults')} + + + + {t('modelManager.width')} + + + + {t('modelManager.height')} + + + + {t('parameters.steps')} + + + + {t('parameters.guidance')} + + + + {t('modelManager.numImages')} + + + + + )} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index 6e114bb252d..c54523e0fad 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -10,14 +10,15 @@ import { TriggerPhrases } from 'features/modelManagerV2/subpanels/ModelPanel/Tri import { filesize } from 'filesize'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { - AnyModelConfig, - CLIPEmbedModelConfig, - CLIPVisionModelConfig, - LlavaOnevisionModelConfig, - Qwen3EncoderModelConfig, - SigLIPModelConfig, - T5EncoderModelConfig, +import { + isExternalApiModelConfig, + type AnyModelConfig, + type CLIPEmbedModelConfig, + type CLIPVisionModelConfig, + type LlavaOnevisionModelConfig, + type Qwen3EncoderModelConfig, + type SigLIPModelConfig, + type T5EncoderModelConfig, } from 'services/api/types'; import { isExternalModel } from './isExternalModel'; @@ -100,6 +101,12 @@ export const ModelView = memo(({ modelConfig }: Props) => { + {isExternalApiModelConfig(modelConfig) && ( + <> + + + + )} {'variant' in modelConfig && modelConfig.variant && ( )} diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index 36805c022d8..e5a4e32701d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -94,6 +94,7 @@ export const zBaseModelType = z.enum([ 'flux2', 'cogview4', 'z-image', + 'external', 'unknown', ]); export type BaseModelType = z.infer; @@ -118,6 +119,7 @@ export const zModelType = z.enum([ 'clip_embed', 'siglip', 'flux_redux', + 'external_image_generator', 'unknown', ]); export type ModelType = z.infer; @@ -167,6 +169,7 @@ export const zModelFormat = z.enum([ 'bnb_quantized_int8b', 'bnb_quantized_nf4b', 'gguf_quantized', + 'external_api', 'unknown', ]); export type ModelFormat = z.infer; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.test.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.test.ts new file mode 100644 index 00000000000..fd787456381 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.test.ts @@ -0,0 +1,154 @@ +import type { RootState } from 'app/store/store'; +import type { ParamsState, RefImagesState } from 'features/controlLayers/store/types'; +import { imageDTOToCroppableImage, initialIPAdapter } from 'features/controlLayers/store/util'; +import type { + ExternalApiModelConfig, + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, + ImageDTO, + Invocation, +} from 'services/api/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildExternalGraph } from './buildExternalGraph'; + +const createExternalModel = (overrides: Partial = {}): ExternalApiModelConfig => { + const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 }; + const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024, steps: 30 }; + const capabilities: ExternalModelCapabilities = { + modes: ['txt2img'], + supports_negative_prompt: true, + supports_reference_images: true, + supports_seed: true, + supports_guidance: true, + max_image_size: maxImageSize, + }; + + return { + key: 'external-test', + hash: 'external:openai:gpt-image-1', + path: 'external://openai/gpt-image-1', + file_size: 0, + name: 'External Test', + description: null, + source: 'external://openai/gpt-image-1', + source_type: 'url', + source_api_response: null, + cover_image: null, + base: 'external', + type: 'external_image_generator', + format: 'external_api', + provider_id: 'openai', + provider_model_id: 'gpt-image-1', + capabilities, + default_settings: defaultSettings, + tags: ['external'], + is_default: false, + ...overrides, + }; +}; + +let mockModelConfig: ExternalApiModelConfig | null = null; +let mockParams: ParamsState; +let mockRefImages: RefImagesState; +let mockPrompts: { positive: string; negative: string }; +let mockSizes: { scaledSize: { width: number; height: number } }; + +const mockOutputFields = { + id: 'external_output', + use_cache: false, + is_intermediate: false, + board: undefined, +}; + +vi.mock('features/controlLayers/store/paramsSlice', () => ({ + selectModelConfig: () => mockModelConfig, + selectParamsSlice: () => mockParams, +})); + +vi.mock('features/controlLayers/store/refImagesSlice', () => ({ + selectRefImagesSlice: () => mockRefImages, +})); + +vi.mock('features/nodes/util/graph/graphBuilderUtils', () => ({ + getOriginalAndScaledSizesForTextToImage: () => mockSizes, + getOriginalAndScaledSizesForOtherModes: () => ({ + scaledSize: { width: 512, height: 512 }, + rect: { x: 0, y: 0, width: 512, height: 512 }, + }), + selectCanvasOutputFields: () => mockOutputFields, + selectPresetModifiedPrompts: () => mockPrompts, +})); + +beforeEach(() => { + mockParams = { + steps: 20, + guidance: 4.5, + } as ParamsState; + mockPrompts = { positive: 'a test prompt', negative: 'bad prompt' }; + mockSizes = { scaledSize: { width: 768, height: 512 } }; + + const imageDTO = { image_name: 'ref.png', width: 64, height: 64 } as ImageDTO; + mockRefImages = { + selectedEntityId: null, + isPanelOpen: false, + entities: [ + { + id: 'ref-image-1', + isEnabled: true, + config: { + ...initialIPAdapter, + weight: 0.5, + image: imageDTOToCroppableImage(imageDTO), + }, + }, + ], + }; +}); + +describe('buildExternalGraph', () => { + it('builds txt2img graph with reference images and seed', async () => { + const modelConfig = createExternalModel(); + mockModelConfig = modelConfig; + + const { g } = await buildExternalGraph({ + generationMode: 'txt2img', + state: {} as RootState, + manager: null, + }); + const graph = g.getGraph(); + const externalNode = Object.values(graph.nodes).find( + (node) => node.type === 'external_image_generation' + ) as Invocation<'external_image_generation'>; + + expect(externalNode).toBeDefined(); + expect(externalNode.mode).toBe('txt2img'); + expect(externalNode.width).toBe(768); + expect(externalNode.height).toBe(512); + expect(externalNode.negative_prompt).toBe('bad prompt'); + expect(externalNode.guidance).toBe(4.5); + expect(externalNode.reference_images?.[0]).toEqual({ image_name: 'ref.png' }); + expect(externalNode.reference_image_weights).toEqual([0.5]); + + const seedEdge = graph.edges.find((edge) => edge.destination.field === 'seed'); + expect(seedEdge).toBeDefined(); + }); + + it('throws when mode is unsupported', async () => { + const modelConfig = createExternalModel({ + capabilities: { + modes: ['img2img'], + }, + }); + mockModelConfig = modelConfig; + + await expect( + buildExternalGraph({ + generationMode: 'txt2img', + state: {} as RootState, + manager: null, + }) + ).rejects.toThrow('does not support txt2img'); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts new file mode 100644 index 00000000000..02c030aa3b8 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts @@ -0,0 +1,129 @@ +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import { selectModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; +import { zImageField } from 'features/nodes/types/common'; +import { Graph } from 'features/nodes/util/graph/generation/Graph'; +import { + getOriginalAndScaledSizesForOtherModes, + getOriginalAndScaledSizesForTextToImage, + selectCanvasOutputFields, + selectPresetModifiedPrompts, +} from 'features/nodes/util/graph/graphBuilderUtils'; +import { + type GraphBuilderArg, + type GraphBuilderReturn, + UnsupportedGenerationModeError, +} from 'features/nodes/util/graph/types'; +import { type Invocation, isExternalApiModelConfig } from 'services/api/types'; +import { assert } from 'tsafe'; + +export const buildExternalGraph = async (arg: GraphBuilderArg): Promise => { + const { generationMode, state, manager } = arg; + + const model = selectModelConfig(state); + assert(model, 'No model selected'); + assert(isExternalApiModelConfig(model), 'Selected model is not an external API model'); + + const requestedMode = generationMode === 'outpaint' ? 'inpaint' : generationMode; + if (!model.capabilities.modes.includes(requestedMode)) { + throw new UnsupportedGenerationModeError(`${model.name} does not support ${requestedMode} mode`); + } + + const params = selectParamsSlice(state); + const refImages = selectRefImagesSlice(state); + const prompts = selectPresetModifiedPrompts(state); + + const g = new Graph(getPrefixedId('external_graph')); + + const seed = model.capabilities.supports_seed + ? g.addNode({ + id: getPrefixedId('seed'), + type: 'integer', + }) + : null; + + const positivePrompt = g.addNode({ + id: getPrefixedId('positive_prompt'), + type: 'string', + }); + + const externalNode = g.addNode({ + id: getPrefixedId('external_image_generation'), + type: 'external_image_generation', + model, + mode: requestedMode, + negative_prompt: model.capabilities.supports_negative_prompt ? prompts.negative : null, + steps: params.steps, + guidance: model.capabilities.supports_guidance ? params.guidance : null, + num_images: 1, + }); + + if (seed) { + g.addEdge(seed, 'value', externalNode, 'seed'); + } + g.addEdge(positivePrompt, 'value', externalNode, 'prompt'); + + if (model.capabilities.supports_reference_images) { + const referenceImages = refImages.entities + .filter((entity) => entity.isEnabled) + .map((entity) => entity.config) + .filter((config) => config.image) + .map((config) => zImageField.parse(config.image?.crop?.image ?? config.image?.original.image)); + + const referenceWeights = refImages.entities + .filter((entity) => entity.isEnabled) + .map((entity) => entity.config) + .filter((config) => config.image) + .map((config) => (config.type === 'ip_adapter' ? config.weight : null)); + + if (referenceImages.length > 0) { + externalNode.reference_images = referenceImages; + if (referenceWeights.every((weight): weight is number => weight !== null)) { + externalNode.reference_image_weights = referenceWeights; + } + } + } + + if (generationMode === 'txt2img') { + const { scaledSize } = getOriginalAndScaledSizesForTextToImage(state); + externalNode.width = scaledSize.width; + externalNode.height = scaledSize.height; + } else { + assert(manager, 'Canvas manager is required for img2img/inpaint'); + const canvasSettings = selectCanvasSettingsSlice(state); + const { scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state); + externalNode.width = scaledSize.width; + externalNode.height = scaledSize.height; + + const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer'); + const initImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, rect, { + is_intermediate: true, + silent: true, + }); + externalNode.init_image = { image_name: initImage.image_name }; + + if (generationMode === 'inpaint' || generationMode === 'outpaint') { + const inpaintMaskAdapters = manager.compositor.getVisibleAdaptersOfType('inpaint_mask'); + const maskImage = await manager.compositor.getGrayscaleMaskCompositeImageDTO( + inpaintMaskAdapters, + rect, + 'denoiseLimit', + canvasSettings.preserveMask, + { + is_intermediate: true, + silent: true, + } + ); + externalNode.mask_image = { image_name: maskImage.image_name }; + } + } + + g.updateNode(externalNode, selectCanvasOutputFields(state)); + + return { + g, + seed: seed ?? undefined, + positivePrompt: positivePrompt as Invocation<'string'>, + }; +}; diff --git a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx new file mode 100644 index 00000000000..1ae1dcdc3a8 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx @@ -0,0 +1,44 @@ +import type { ExternalApiModelConfig } from 'services/api/types'; +import { describe, expect, test } from 'vitest'; + +const createExternalModel = (overrides: Partial = {}): ExternalApiModelConfig => ({ + key: 'external-test', + name: 'External Test', + base: 'external', + type: 'external_image_generator', + format: 'external_api', + provider_id: 'gemini', + provider_model_id: 'gemini-2.5-flash-image', + description: 'Test model', + source: 'external://gemini/gemini-2.5-flash-image', + source_type: 'url', + source_api_response: null, + path: '', + file_size: 0, + hash: 'external:gemini:gemini-2.5-flash-image', + cover_image: null, + is_default: false, + tags: ['external'], + capabilities: { + modes: ['txt2img'], + supports_reference_images: false, + supports_negative_prompt: true, + supports_seed: true, + supports_guidance: true, + max_images_per_request: 1, + max_image_size: null, + allowed_aspect_ratios: ['1:1', '16:9'], + max_reference_images: null, + mask_format: 'none', + input_image_required_for: null, + }, + default_settings: null, + ...overrides, +}); + +describe('external model aspect ratios (bbox)', () => { + test('uses allowed aspect ratios for external models', () => { + const model = createExternalModel(); + expect(model.capabilities.allowed_aspect_ratios).toEqual(['1:1', '16:9']); + }); +}); diff --git a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.tsx index a237896c676..28dcb54cd7b 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.tsx @@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { bboxAspectRatioIdChanged } from 'features/controlLayers/store/canvasSlice'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectAllowedAspectRatioIDs } from 'features/controlLayers/store/paramsSlice'; import { selectAspectRatioID } from 'features/controlLayers/store/selectors'; import { isAspectRatioID, zAspectRatioID } from 'features/controlLayers/store/types'; import type { ChangeEventHandler } from 'react'; @@ -15,6 +16,8 @@ export const BboxAspectRatioSelect = memo(() => { const dispatch = useAppDispatch(); const id = useAppSelector(selectAspectRatioID); const isStaging = useCanvasIsStaging(); + const allowedAspectRatios = useAppSelector(selectAllowedAspectRatioIDs); + const options = allowedAspectRatios ?? zAspectRatioID.options; const onChange = useCallback>( (e) => { @@ -32,7 +35,7 @@ export const BboxAspectRatioSelect = memo(() => { {t('parameters.aspect')} }> - {zAspectRatioID.options.map((ratio) => ( + {options.map((ratio) => ( diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts new file mode 100644 index 00000000000..b908efa096e --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts @@ -0,0 +1,61 @@ +import type { + ExternalApiModelConfig, + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, +} from 'services/api/types'; +import { describe, expect, it } from 'vitest'; + +import { isExternalModelUnsupportedForTab } from './mainModelPickerUtils'; + +const createExternalConfig = (modes: ExternalModelCapabilities['modes']): ExternalApiModelConfig => { + const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 }; + const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024, steps: 30 }; + + return { + key: 'external-test', + hash: 'external:openai:gpt-image-1', + path: 'external://openai/gpt-image-1', + file_size: 0, + name: 'External Test', + description: null, + source: 'external://openai/gpt-image-1', + source_type: 'url', + source_api_response: null, + cover_image: null, + base: 'external', + type: 'external_image_generator', + format: 'external_api', + provider_id: 'openai', + provider_model_id: 'gpt-image-1', + capabilities: { + modes, + supports_negative_prompt: true, + supports_reference_images: false, + max_image_size: maxImageSize, + }, + default_settings: defaultSettings, + tags: ['external'], + is_default: false, + }; +}; + +describe('isExternalModelUnsupportedForTab', () => { + it('disables external models without txt2img for generate', () => { + const model = createExternalConfig(['img2img', 'inpaint']); + + expect(isExternalModelUnsupportedForTab(model, 'generate')).toBe(true); + }); + + it('allows external models with txt2img for generate', () => { + const model = createExternalConfig(['txt2img']); + + expect(isExternalModelUnsupportedForTab(model, 'generate')).toBe(false); + }); + + it('allows external models on canvas', () => { + const model = createExternalConfig(['inpaint']); + + expect(isExternalModelUnsupportedForTab(model, 'canvas')).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts new file mode 100644 index 00000000000..bc20c1a1184 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts @@ -0,0 +1,14 @@ +import type { TabName } from 'features/ui/store/uiTypes'; +import { type AnyModelConfig, isExternalApiModelConfig } from 'services/api/types'; + +export const isExternalModelUnsupportedForTab = (model: AnyModelConfig, tab: TabName): boolean => { + if (!isExternalApiModelConfig(model)) { + return false; + } + + if (tab === 'generate') { + return !model.capabilities.modes.includes('txt2img'); + } + + return false; +}; diff --git a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx index c5397791b84..b8631f5f742 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx @@ -1,5 +1,6 @@ import type { BoxProps, ButtonProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { + Badge, Button, Flex, Icon, @@ -33,7 +34,7 @@ import { memo, useCallback, useMemo, useRef } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { PiCaretDownBold, PiLinkSimple } from 'react-icons/pi'; import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships'; -import type { AnyModelConfig } from 'services/api/types'; +import { type AnyModelConfig, type ExternalApiModelConfig, isExternalApiModelConfig } from 'services/api/types'; const selectSelectedModelKeys = createMemoizedSelector(selectParamsSlice, selectLoRAsSlice, (params, loras) => { const keys: string[] = []; @@ -94,9 +95,7 @@ const NoOptionsFallback = memo(({ noOptionsText }: { noOptionsText?: string }) = }); NoOptionsFallback.displayName = 'NoOptionsFallback'; -const getGroupIDFromModelConfig = (modelConfig: AnyModelConfig): string => { - return modelConfig.base; -}; +const getGroupIDFromModelConfig = (modelConfig: AnyModelConfig): string => modelConfig.base; const getGroupNameFromModelConfig = (modelConfig: AnyModelConfig): string => { return MODEL_BASE_TO_LONG_NAME[modelConfig.base]; @@ -387,6 +386,10 @@ const optionNameSx: SystemStyleObject = { const PickerOptionComponent = typedMemo( ({ option, ...rest }: { option: WithStarred } & BoxProps) => { const { isCompactView } = usePickerContext>(); + const externalOption = isExternalApiModelConfig(option as AnyModelConfig) + ? (option as ExternalApiModelConfig) + : null; + const providerLabel = externalOption ? externalOption.provider_id.toUpperCase() : null; return ( @@ -397,6 +400,15 @@ const PickerOptionComponent = typedMemo( {option.name} + {!isCompactView && externalOption && ( + + {providerLabel} + + )} {option.file_size > 0 && ( (model: WithStarred, searchTerm: string) => { const regex = getRegex(searchTerm); const bases = BASE_KEYWORDS[model.base] ?? [model.base]; + const externalModel = isExternalApiModelConfig(model as AnyModelConfig) ? (model as ExternalApiModelConfig) : null; + const externalSearch = externalModel ? ` ${externalModel.provider_id} ${externalModel.provider_model_id}` : ''; const testString = - `${model.name} ${bases.join(' ')} ${model.type} ${model.description ?? ''} ${model.format}`.toLowerCase(); + `${model.name} ${bases.join(' ')} ${model.type} ${model.description ?? ''} ${model.format}${externalSearch}`.toLowerCase(); if (testString.includes(searchTerm) || regex.test(testString)) { return true; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts index 652cf4c5b24..5bfc31d10fd 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts @@ -9,6 +9,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph'; +import { buildExternalGraph } from 'features/nodes/util/graph/generation/buildExternalGraph'; import { buildFLUXGraph } from 'features/nodes/util/graph/generation/buildFLUXGraph'; import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph'; import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph'; @@ -59,6 +60,8 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep return await buildCogView4Graph(graphBuilderArg); case 'z-image': return await buildZImageGraph(graphBuilderArg); + case 'external': + return await buildExternalGraph(graphBuilderArg); default: assert(false, `No graph builders for base ${base}`); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts index cf00a12ee5f..c50f833ba85 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts @@ -7,6 +7,7 @@ import { withResult, withResultAsync } from 'common/util/result'; import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph'; +import { buildExternalGraph } from 'features/nodes/util/graph/generation/buildExternalGraph'; import { buildFLUXGraph } from 'features/nodes/util/graph/generation/buildFLUXGraph'; import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph'; import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph'; @@ -52,6 +53,8 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => { return await buildCogView4Graph(graphBuilderArg); case 'z-image': return await buildZImageGraph(graphBuilderArg); + case 'external': + return await buildExternalGraph(graphBuilderArg); default: assert(false, `No graph builders for base ${base}`); } diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 8fa97eff4a9..61955e82c92 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -39,7 +39,8 @@ import type { TabName } from 'features/ui/store/uiTypes'; import i18n from 'i18next'; import { atom, computed } from 'nanostores'; import { useEffect } from 'react'; -import type { MainModelConfig } from 'services/api/types'; +import type { MainOrExternalModelConfig } from 'services/api/types'; +import { isExternalApiModelConfig } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; /** @@ -221,7 +222,7 @@ const disconnectedReason = (t: typeof i18n.t) => ({ content: t('parameters.invok const getReasonsWhyCannotEnqueueGenerateTab = (arg: { isConnected: boolean; - model: MainModelConfig | null | undefined; + model: MainOrExternalModelConfig | null | undefined; params: ParamsState; refImages: RefImagesState; loras: LoRA[]; @@ -243,7 +244,11 @@ const getReasonsWhyCannotEnqueueGenerateTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); } - if (model?.base === 'flux') { + if (!model) { + // nothing else to validate + } else if (isExternalApiModelConfig(model)) { + // external models don't require local sub-models + } else if (model.base === 'flux') { if (!params.t5EncoderModel) { reasons.push({ content: i18n.t('parameters.invoke.noT5EncoderModelSelected') }); } @@ -280,7 +285,7 @@ const getReasonsWhyCannotEnqueueGenerateTab = (arg: { } } - if (model && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base)) { + if (model && !isExternalApiModelConfig(model) && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base)) { const enabledRefImages = refImages.entities.filter(({ isEnabled }) => isEnabled); enabledRefImages.forEach((entity, i) => { @@ -431,7 +436,7 @@ const getReasonsWhyCannotEnqueueUpscaleTab = (arg: { const getReasonsWhyCannotEnqueueCanvasTab = (arg: { isConnected: boolean; - model: MainModelConfig | null | undefined; + model: MainOrExternalModelConfig | null | undefined; canvas: CanvasState; params: ParamsState; refImages: RefImagesState; @@ -488,7 +493,11 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); } - if (model?.base === 'flux') { + if (!model) { + // nothing else to validate + } else if (isExternalApiModelConfig(model)) { + // external models don't require local sub-models + } else if (model.base === 'flux') { if (!params.t5EncoderModel) { reasons.push({ content: i18n.t('parameters.invoke.noT5EncoderModelSelected') }); } @@ -682,7 +691,7 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { } }); - if (model && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base)) { + if (model && !isExternalApiModelConfig(model) && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base)) { const enabledRefImages = refImages.entities.filter(({ isEnabled }) => isEnabled); enabledRefImages.forEach((entity, i) => { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx index 773b67e39bb..91f5f1efd0a 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx @@ -1,9 +1,11 @@ import { Flex, FormLabel, Icon } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { isExternalModelUnsupportedForTab } from 'features/parameters/components/MainModel/mainModelPickerUtils'; import { UseDefaultSettingsButton } from 'features/parameters/components/MainModel/UseDefaultSettingsButton'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; import { modelSelected } from 'features/parameters/store/actions'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MdMoneyOff } from 'react-icons/md'; @@ -14,6 +16,7 @@ import { type AnyModelConfig, isNonCommercialMainModelConfig } from 'services/ap export const MainModelPicker = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const activeTab = useAppSelector(selectActiveTab); const [modelConfigs] = useMainModels(); const selectedModelConfig = useSelectedModelConfig(); const onChange = useCallback( @@ -28,6 +31,11 @@ export const MainModelPicker = memo(() => { [selectedModelConfig] ); + const getIsOptionDisabled = useCallback( + (modelConfig: AnyModelConfig) => isExternalModelUnsupportedForTab(modelConfig, activeTab), + [activeTab] + ); + return ( @@ -46,6 +54,7 @@ export const MainModelPicker = memo(() => { selectedModelConfig={selectedModelConfig} onChange={onChange} grouped + getIsOptionDisabled={getIsOptionDisabled} /> diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/ExternalProviderStatusList.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/ExternalProviderStatusList.tsx new file mode 100644 index 00000000000..ea36cf4c65a --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/ExternalProviderStatusList.tsx @@ -0,0 +1,39 @@ +import { Badge, Flex, FormControl, FormLabel, Text, Tooltip } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGetExternalProviderStatusesQuery } from 'services/api/endpoints/appInfo'; + +import { getExternalProviderStatusBadgeInfo } from './externalProviderStatusUtils'; + +export const ExternalProviderStatusList = memo(() => { + const { t } = useTranslation(); + const { data } = useGetExternalProviderStatusesQuery(); + + if (!data || data.length === 0) { + return null; + } + + const sortedProviders = [...data].sort((a, b) => a.provider_id.localeCompare(b.provider_id)); + + return ( + + {t('settings.externalProviders')} + + {sortedProviders.map((status) => { + const badgeInfo = getExternalProviderStatusBadgeInfo(status); + const tooltip = badgeInfo.tooltipMessage ?? (badgeInfo.tooltipKey ? t(badgeInfo.tooltipKey) : null); + return ( + + {status.provider_id} + + {t(badgeInfo.labelKey)} + + + ); + })} + + + ); +}); + +ExternalProviderStatusList.displayName = 'ExternalProviderStatusList'; diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 6c7ebada8f0..b94669e92f0 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -20,6 +20,7 @@ import { InformationalPopover } from 'common/components/InformationalPopover/Inf import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { buildUseBoolean } from 'common/hooks/useBoolean'; import { selectShouldUseCPUNoise, shouldUseCpuNoiseChanged } from 'features/controlLayers/store/paramsSlice'; +import { ExternalProviderStatusList } from 'features/system/components/SettingsModal/ExternalProviderStatusList'; import { useRefreshAfterResetModal } from 'features/system/components/SettingsModal/RefreshAfterResetModal'; import { SettingsDeveloperLogIsEnabled } from 'features/system/components/SettingsModal/SettingsDeveloperLogIsEnabled'; import { SettingsDeveloperLogLevel } from 'features/system/components/SettingsModal/SettingsDeveloperLogLevel'; @@ -48,8 +49,7 @@ import { } from 'features/system/store/systemSlice'; import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import { setShouldShowProgressInViewer } from 'features/ui/store/uiSlice'; -import type { ChangeEvent, ReactElement } from 'react'; -import { cloneElement, memo, useCallback, useEffect } from 'react'; +import { type ChangeEvent, cloneElement, memo, type ReactElement, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { SettingsLanguageSelect } from './SettingsLanguageSelect'; @@ -198,6 +198,10 @@ const SettingsModal = (props: { children: ReactElement }) => { + + + + {t('settings.showProgressInViewer')} diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.test.ts b/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.test.ts new file mode 100644 index 00000000000..98ae63004c3 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.test.ts @@ -0,0 +1,38 @@ +import type { ExternalProviderStatus } from 'services/api/types'; +import { describe, expect, it } from 'vitest'; + +import { getExternalProviderStatusBadgeInfo } from './externalProviderStatusUtils'; + +const buildStatus = (overrides: Partial = {}): ExternalProviderStatus => ({ + provider_id: 'openai', + configured: false, + message: null, + ...overrides, +}); + +describe('getExternalProviderStatusBadgeInfo', () => { + it('marks configured providers as configured', () => { + const badgeInfo = getExternalProviderStatusBadgeInfo(buildStatus({ configured: true })); + + expect(badgeInfo.labelKey).toBe('settings.externalProviderConfigured'); + expect(badgeInfo.tooltipKey).toBeNull(); + expect(badgeInfo.tooltipMessage).toBeNull(); + expect(badgeInfo.colorScheme).toBe('green'); + }); + + it('adds hint when provider is not configured', () => { + const badgeInfo = getExternalProviderStatusBadgeInfo(buildStatus()); + + expect(badgeInfo.labelKey).toBe('settings.externalProviderNotConfigured'); + expect(badgeInfo.tooltipKey).toBe('settings.externalProviderNotConfiguredHint'); + expect(badgeInfo.tooltipMessage).toBeNull(); + expect(badgeInfo.colorScheme).toBe('warning'); + }); + + it('prefers status messages when present', () => { + const badgeInfo = getExternalProviderStatusBadgeInfo(buildStatus({ message: 'Missing key' })); + + expect(badgeInfo.tooltipKey).toBeNull(); + expect(badgeInfo.tooltipMessage).toBe('Missing key'); + }); +}); diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.ts b/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.ts new file mode 100644 index 00000000000..fb1f764e2a4 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/externalProviderStatusUtils.ts @@ -0,0 +1,26 @@ +import type { ExternalProviderStatus } from 'services/api/types'; + +type ExternalProviderStatusBadgeInfo = { + labelKey: 'settings.externalProviderConfigured' | 'settings.externalProviderNotConfigured'; + tooltipKey: 'settings.externalProviderNotConfiguredHint' | null; + tooltipMessage: string | null; + colorScheme: 'green' | 'warning'; +}; + +export const getExternalProviderStatusBadgeInfo = (status: ExternalProviderStatus): ExternalProviderStatusBadgeInfo => { + if (status.configured) { + return { + labelKey: 'settings.externalProviderConfigured', + tooltipKey: null, + tooltipMessage: status.message ?? null, + colorScheme: 'green', + }; + } + + return { + labelKey: 'settings.externalProviderNotConfigured', + tooltipKey: status.message ? null : 'settings.externalProviderNotConfiguredHint', + tooltipMessage: status.message ?? null, + colorScheme: 'warning', + }; +}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx b/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx index 0d71b621734..07da9e6e18b 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx @@ -1,8 +1,10 @@ import { Flex, FormControl, FormLabel, Icon } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { isExternalModelUnsupportedForTab } from 'features/parameters/components/MainModel/mainModelPickerUtils'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; import { modelSelected } from 'features/parameters/store/actions'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MdMoneyOff } from 'react-icons/md'; @@ -13,6 +15,7 @@ import { type AnyModelConfig, isNonCommercialMainModelConfig } from 'services/ap export const InitialStateMainModelPicker = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const activeTab = useAppSelector(selectActiveTab); const [modelConfigs] = useMainModels(); const selectedModelConfig = useSelectedModelConfig(); const onChange = useCallback( @@ -27,6 +30,11 @@ export const InitialStateMainModelPicker = memo(() => { [selectedModelConfig] ); + const getIsOptionDisabled = useCallback( + (modelConfig: AnyModelConfig) => isExternalModelUnsupportedForTab(modelConfig, activeTab), + [activeTab] + ); + return ( @@ -45,6 +53,7 @@ export const InitialStateMainModelPicker = memo(() => { selectedModelConfig={selectedModelConfig} onChange={onChange} grouped + getIsOptionDisabled={getIsOptionDisabled} /> ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts index 8fe85125e6a..9f01c717108 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts @@ -1,7 +1,7 @@ import type { OpenAPIV3_1 } from 'openapi-types'; import type { stringify } from 'querystring'; import type { paths } from 'services/api/schema'; -import type { AppVersion } from 'services/api/types'; +import type { AppVersion, ExternalProviderConfig, ExternalProviderStatus } from 'services/api/types'; import { api, buildV1Url } from '..'; @@ -52,6 +52,35 @@ export const appInfoApi = api.injectEndpoints({ method: 'GET', }), }), + getExternalProviderStatuses: build.query({ + query: () => ({ + url: buildAppInfoUrl('external_providers/status'), + method: 'GET', + }), + providesTags: ['FetchOnReconnect'], + }), + getExternalProviderConfigs: build.query({ + query: () => ({ + url: buildAppInfoUrl('external_providers/config'), + method: 'GET', + }), + providesTags: ['AppConfig', 'FetchOnReconnect'], + }), + setExternalProviderConfig: build.mutation({ + query: ({ provider_id, ...body }) => ({ + url: buildAppInfoUrl(`external_providers/config/${provider_id}`), + method: 'POST', + body, + }), + invalidatesTags: ['AppConfig', 'FetchOnReconnect'], + }), + resetExternalProviderConfig: build.mutation({ + query: (provider_id) => ({ + url: buildAppInfoUrl(`external_providers/config/${provider_id}`), + method: 'DELETE', + }), + invalidatesTags: ['AppConfig', 'FetchOnReconnect'], + }), getInvocationCacheStatus: build.query< paths['/api/v1/app/invocation_cache/status']['get']['responses']['200']['content']['application/json'], void @@ -95,6 +124,10 @@ export const { useGetAppDepsQuery, useGetPatchmatchStatusQuery, useGetRuntimeConfigQuery, + useGetExternalProviderStatusesQuery, + useGetExternalProviderConfigsQuery, + useSetExternalProviderConfigMutation, + useResetExternalProviderConfigMutation, useClearInvocationCacheMutation, useDisableInvocationCacheMutation, useEnableInvocationCacheMutation, @@ -102,3 +135,8 @@ export const { useGetOpenAPISchemaQuery, useLazyGetOpenAPISchemaQuery, } = appInfoApi; + +type SetExternalProviderConfigArg = + paths['/api/v1/app/external_providers/config/{provider_id}']['post']['requestBody']['content']['application/json'] & { + provider_id: paths['/api/v1/app/external_providers/config/{provider_id}']['post']['parameters']['path']['provider_id']; + }; diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts index 98d7dd1e8df..fa3218d400e 100644 --- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts +++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts @@ -9,11 +9,12 @@ import { useGetMissingModelsQuery, useGetModelConfigsQuery, } from 'services/api/endpoints/models'; -import type { AnyModelConfig } from 'services/api/types'; +import type { AnyModelConfig, MainOrExternalModelConfig } from 'services/api/types'; import { isCLIPEmbedModelConfigOrSubmodel, isControlLayerModelConfig, isControlNetModelConfig, + isExternalApiModelConfig, isFlux1VAEModelConfig, isFlux2VAEModelConfig, isFluxKontextModelConfig, @@ -21,7 +22,7 @@ import { isFluxVAEModelConfig, isIPAdapterModelConfig, isLoRAModelConfig, - isNonRefinerMainModelConfig, + isMainOrExternalModelConfig, isQwen3EncoderModelConfig, isRefinerMainModelModelConfig, isSpandrelImageToImageModelConfig, @@ -50,13 +51,13 @@ const buildModelsHook = return modelConfigsAdapterSelectors .selectAll(result.data) .filter((config) => typeGuard(config)) - .filter((config) => !missingModelKeys.has(config.key)) + .filter((config) => !missingModelKeys.has(config.key) || isExternalApiModelConfig(config)) .filter(filter); }, [filter, result.data, missingModelsData]); return [modelConfigs, result] as const; }; -export const useMainModels = buildModelsHook(isNonRefinerMainModelConfig); +export const useMainModels = buildModelsHook(isMainOrExternalModelConfig); export const useRefinerModels = buildModelsHook(isRefinerMainModelModelConfig); export const useLoRAModels = buildModelsHook(isLoRAModelConfig); export const useControlLayerModels = buildModelsHook(isControlLayerModelConfig); @@ -94,7 +95,7 @@ const buildModelsSelector = return modelConfigsAdapterSelectors .selectAll(result.data) .filter(typeGuard) - .filter((config) => !missingModelKeys.has(config.key)); + .filter((config) => !missingModelKeys.has(config.key) || isExternalApiModelConfig(config)); }; export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig); export const selectGlobalRefImageModels = buildModelsSelector( diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b605413787b..392723bef19 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -10434,7 +10434,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExternalImageGenerationInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; }; /** * Edges @@ -13656,7 +13656,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExternalImageGenerationInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -13720,7 +13720,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExternalImageGenerationInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -14026,7 +14026,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExternalImageGenerationInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -14101,7 +14101,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExternalImageGenerationInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 5d56c346f87..0c06e04dcd6 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -43,6 +43,9 @@ export type InvocationJSONSchemaExtra = S['UIConfigBase']; // App Info export type AppVersion = S['AppVersion']; +export type ExternalProviderStatus = S['ExternalProviderStatusModel']; +export type ExternalProviderConfig = S['ExternalProviderConfigModel']; +export type UpdateModelBody = paths['/api/v2/models/i/{key}']['patch']['requestBody']['content']['application/json']; const zResourceOrigin = z.enum(['internal', 'external']); type ResourceOrigin = z.infer; @@ -110,6 +113,46 @@ export type ChatGPT4oModelConfig = ApiModelConfig; export type Gemini2_5ModelConfig = ApiModelConfig; type SubmodelDefinition = S['SubmodelDefinition']; +export type ExternalImageSize = { + width: number; + height: number; +}; + +export type ExternalModelCapabilities = { + modes: ('txt2img' | 'img2img' | 'inpaint')[]; + supports_reference_images?: boolean; + supports_negative_prompt?: boolean; + supports_seed?: boolean; + supports_guidance?: boolean; + max_images_per_request?: number | null; + max_image_size?: ExternalImageSize | null; + allowed_aspect_ratios?: string[] | null; + max_reference_images?: number | null; + mask_format?: 'alpha' | 'binary' | 'none'; + input_image_required_for?: ('txt2img' | 'img2img' | 'inpaint')[] | null; +}; + +export type ExternalApiModelDefaultSettings = { + width?: number | null; + height?: number | null; + steps?: number | null; + guidance?: number | null; + num_images?: number | null; +}; + +export type ExternalApiModelConfig = AnyModelConfig & { + base: 'external'; + type: 'external_image_generator'; + format: 'external_api'; + provider_id: string; + provider_model_id: string; + capabilities: ExternalModelCapabilities; + default_settings?: ExternalApiModelDefaultSettings | null; + tags?: string[] | null; + is_default?: boolean; +}; +export type MainOrExternalModelConfig = MainModelConfig | ExternalApiModelConfig; + /** * Checks if a list of submodels contains any that match a given variant or type * @param submodels The list of submodels to check @@ -290,6 +333,10 @@ export const isFluxReduxModelConfig = (config: AnyModelConfig): config is FLUXRe return config.type === 'flux_redux'; }; +export const isExternalApiModelConfig = (config: AnyModelConfig): config is ExternalApiModelConfig => { + return (config as { format?: string }).format === 'external_api'; +}; + export const isUnknownModelConfig = (config: AnyModelConfig): config is UnknownModelConfig => { return config.type === 'unknown'; }; @@ -302,6 +349,10 @@ export const isNonRefinerMainModelConfig = (config: AnyModelConfig): config is M return config.type === 'main' && config.base !== 'sdxl-refiner'; }; +export const isMainOrExternalModelConfig = (config: AnyModelConfig): config is MainOrExternalModelConfig => { + return isNonRefinerMainModelConfig(config) || isExternalApiModelConfig(config); +}; + export const isRefinerMainModelModelConfig = (config: AnyModelConfig): config is MainModelConfig => { return config.type === 'main' && config.base === 'sdxl-refiner'; }; diff --git a/tests/app/invocations/test_external_image_generation.py b/tests/app/invocations/test_external_image_generation.py new file mode 100644 index 00000000000..3ede7aef421 --- /dev/null +++ b/tests/app/invocations/test_external_image_generation.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from PIL import Image + +from invokeai.app.invocations.external_image_generation import ExternalImageGenerationInvocation +from invokeai.app.invocations.fields import ImageField +from invokeai.app.invocations.model import ModelIdentifierField +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGeneratedImage, + ExternalGenerationResult, +) +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities + + +def _build_model() -> ExternalApiModelConfig: + return ExternalApiModelConfig( + key="external_test", + name="External Test", + provider_id="openai", + provider_model_id="gpt-image-1", + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_reference_images=True, + supports_negative_prompt=True, + supports_seed=True, + ), + ) + + +def _build_context(model_config: ExternalApiModelConfig, generated_image: Image.Image) -> MagicMock: + context = MagicMock() + context.models.get_config.return_value = model_config + context.images.get_pil.return_value = generated_image + context.images.save.return_value = SimpleNamespace(image_name="result.png") + context._services.external_generation.generate.return_value = ExternalGenerationResult( + images=[ExternalGeneratedImage(image=generated_image, seed=42)], + provider_request_id="req-123", + provider_metadata={"model": model_config.provider_model_id}, + ) + return context + + +def test_external_invocation_builds_request_and_outputs() -> None: + model_config = _build_model() + model_field = ModelIdentifierField.from_config(model_config) + generated_image = Image.new("RGB", (16, 16), color="black") + context = _build_context(model_config, generated_image) + + invocation = ExternalImageGenerationInvocation( + id="external_node", + model=model_field, + mode="txt2img", + prompt="A prompt", + negative_prompt="bad", + seed=123, + num_images=1, + width=512, + height=512, + steps=10, + guidance=4.5, + reference_images=[ImageField(image_name="ref.png")], + reference_image_weights=[0.6], + ) + + output = invocation.invoke(context) + + request = context._services.external_generation.generate.call_args[0][0] + assert request.prompt == "A prompt" + assert request.negative_prompt == "bad" + assert request.seed == 123 + assert len(request.reference_images) == 1 + assert request.reference_images[0].weight == 0.6 + assert output.collection[0].image_name == "result.png" + + +def test_external_invocation_rejects_mismatched_reference_weights() -> None: + model_config = _build_model() + model_field = ModelIdentifierField.from_config(model_config) + generated_image = Image.new("RGB", (16, 16), color="black") + context = _build_context(model_config, generated_image) + + invocation = ExternalImageGenerationInvocation( + id="external_node", + model=model_field, + mode="txt2img", + prompt="A prompt", + reference_images=[ImageField(image_name="ref.png")], + reference_image_weights=[0.1, 0.2], + ) + + with pytest.raises(ValueError, match="reference_image_weights"): + invocation.invoke(context) + + +def test_external_graph_execution_state_runs_node() -> None: + model_config = _build_model() + model_field = ModelIdentifierField.from_config(model_config) + generated_image = Image.new("RGB", (16, 16), color="black") + context = _build_context(model_config, generated_image) + + invocation = ExternalImageGenerationInvocation( + id="external_node", + model=model_field, + mode="txt2img", + prompt="A prompt", + ) + + graph = Graph() + graph.add_node(invocation) + + session = GraphExecutionState(graph=graph) + node = session.next() + assert node is not None + output = node.invoke(context) + session.complete(node.id, output) + + assert session.results[node.id] == output diff --git a/tests/app/routers/test_app_info.py b/tests/app/routers/test_app_info.py new file mode 100644 index 00000000000..12201249ef4 --- /dev/null +++ b/tests/app/routers/test_app_info.py @@ -0,0 +1,93 @@ +import os +import os +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.config.config_default import get_config, load_and_migrate_config +from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus +from invokeai.app.services.invoker import Invoker + + +@pytest.fixture(autouse=True, scope="module") +def client(invokeai_root_dir: Path) -> TestClient: + os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix() + return TestClient(app) + + +class MockApiDependencies(ApiDependencies): + invoker: Invoker + + def __init__(self, invoker: Invoker) -> None: + self.invoker = invoker + + +def test_get_external_provider_statuses(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + statuses = { + "gemini": ExternalProviderStatus(provider_id="gemini", configured=True, message=None), + "openai": ExternalProviderStatus(provider_id="openai", configured=False, message="Missing key"), + } + + monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr(mock_invoker.services.external_generation, "get_provider_statuses", lambda: statuses) + + response = client.get("/api/v1/app/external_providers/status") + + assert response.status_code == 200 + payload = sorted(response.json(), key=lambda item: item["provider_id"]) + assert payload == [ + {"provider_id": "gemini", "configured": True, "message": None}, + {"provider_id": "openai", "configured": False, "message": "Missing key"}, + ] + + +def test_external_provider_config_update_and_reset(client: TestClient) -> None: + for provider_id in ("gemini", "openai"): + response = client.delete(f"/api/v1/app/external_providers/config/{provider_id}") + assert response.status_code == 200 + + response = client.get("/api/v1/app/external_providers/config") + assert response.status_code == 200 + payload = response.json() + openai_config = _get_provider_config(payload, "openai") + assert openai_config["api_key_configured"] is False + assert openai_config["base_url"] is None + + response = client.post( + "/api/v1/app/external_providers/config/openai", + json={"api_key": "openai-key", "base_url": "https://api.openai.test"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["api_key_configured"] is True + assert payload["base_url"] == "https://api.openai.test" + + response = client.get("/api/v1/app/external_providers/config") + assert response.status_code == 200 + payload = response.json() + openai_config = _get_provider_config(payload, "openai") + assert openai_config["api_key_configured"] is True + assert openai_config["base_url"] == "https://api.openai.test" + + config_path = get_config().config_file_path + file_config = load_and_migrate_config(config_path) + assert file_config.external_openai_api_key == "openai-key" + assert file_config.external_openai_base_url == "https://api.openai.test" + + response = client.delete("/api/v1/app/external_providers/config/openai") + assert response.status_code == 200 + payload = response.json() + assert payload["api_key_configured"] is False + assert payload["base_url"] is None + + file_config = load_and_migrate_config(config_path) + assert file_config.external_openai_api_key is None + assert file_config.external_openai_base_url is None + + +def _get_provider_config(payload: list[dict[str, Any]], provider_id: str) -> dict[str, Any]: + return next(item for item in payload if item["provider_id"] == provider_id) diff --git a/tests/app/routers/test_model_manager.py b/tests/app/routers/test_model_manager.py new file mode 100644 index 00000000000..8f69ffce371 --- /dev/null +++ b/tests/app/routers/test_model_manager.py @@ -0,0 +1,71 @@ +import os +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities +from invokeai.backend.model_manager.taxonomy import ModelType + + +@pytest.fixture(autouse=True, scope="module") +def client(invokeai_root_dir: Path) -> TestClient: + os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix() + return TestClient(app) + + +class DummyModelImages: + def get_url(self, key: str) -> str: + return f"https://example.com/models/{key}.png" + + +class DummyInvoker: + def __init__(self, services: Any) -> None: + self.services = services + + +class MockApiDependencies(ApiDependencies): + invoker: DummyInvoker + + def __init__(self, invoker: DummyInvoker) -> None: + self.invoker = invoker + + +def test_model_manager_external_config_round_trip( + monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any +) -> None: + config = ExternalApiModelConfig( + key="external_test", + name="External Test", + provider_id="openai", + provider_model_id="gpt-image-1", + capabilities=ExternalModelCapabilities(modes=["txt2img"]), + ) + mm2_model_manager.store.add_model(config) + + services = type("Services", (), {})() + services.model_manager = mm2_model_manager + services.model_images = DummyModelImages() + services.configuration = mm2_app_config + + invoker = DummyInvoker(services) + monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker)) + + response = client.get("/api/v2/models/", params={"model_type": ModelType.ExternalImageGenerator.value}) + + assert response.status_code == 200 + payload = response.json() + assert len(payload["models"]) == 1 + assert payload["models"][0]["key"] == "external_test" + assert payload["models"][0]["provider_id"] == "openai" + assert payload["models"][0]["cover_image"] == "https://example.com/models/external_test.png" + + get_response = client.get("/api/v2/models/i/external_test") + + assert get_response.status_code == 200 + model_payload = get_response.json() + assert model_payload["provider_model_id"] == "gpt-image-1" + assert model_payload["cover_image"] == "https://example.com/models/external_test.png" diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py new file mode 100644 index 00000000000..8379b8b754c --- /dev/null +++ b/tests/app/services/external_generation/test_external_generation_service.py @@ -0,0 +1,243 @@ +import logging + +import pytest +from PIL import Image + +from invokeai.app.services.external_generation.errors import ( + ExternalProviderCapabilityError, + ExternalProviderNotConfiguredError, + ExternalProviderNotFoundError, +) +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGeneratedImage, + ExternalGenerationRequest, + ExternalGenerationResult, + ExternalReferenceImage, +) +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.external_generation.external_generation_base import ExternalProvider +from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelConfig, + ExternalImageSize, + ExternalModelCapabilities, +) + + +class DummyProvider(ExternalProvider): + def __init__(self, provider_id: str, configured: bool, result: ExternalGenerationResult | None = None) -> None: + super().__init__(InvokeAIAppConfig(), logging.getLogger("test")) + self.provider_id = provider_id + self._configured = configured + self._result = result + self.last_request: ExternalGenerationRequest | None = None + + def is_configured(self) -> bool: + return self._configured + + def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult: + self.last_request = request + assert self._result is not None + return self._result + + +def _build_model(capabilities: ExternalModelCapabilities) -> ExternalApiModelConfig: + return ExternalApiModelConfig( + key="external_test", + name="External Test", + provider_id="openai", + provider_model_id="gpt-image-1", + capabilities=capabilities, + ) + + +def _build_request( + *, + model: ExternalApiModelConfig, + mode: str = "txt2img", + negative_prompt: str | None = None, + seed: int | None = None, + num_images: int = 1, + guidance: float | None = None, + width: int = 64, + height: int = 64, + init_image: Image.Image | None = None, + mask_image: Image.Image | None = None, + reference_images: list[ExternalReferenceImage] | None = None, +) -> ExternalGenerationRequest: + return ExternalGenerationRequest( + model=model, + mode=mode, # type: ignore[arg-type] + prompt="A test prompt", + negative_prompt=negative_prompt, + seed=seed, + num_images=num_images, + width=width, + height=height, + steps=10, + guidance=guidance, + init_image=init_image, + mask_image=mask_image, + reference_images=reference_images or [], + metadata=None, + ) + + +def _make_image() -> Image.Image: + return Image.new("RGB", (64, 64), color="black") + + +def test_generate_requires_registered_provider() -> None: + model = _build_model(ExternalModelCapabilities(modes=["txt2img"])) + request = _build_request(model=model) + service = ExternalGenerationService({}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderNotFoundError): + service.generate(request) + + +def test_generate_requires_configured_provider() -> None: + model = _build_model(ExternalModelCapabilities(modes=["txt2img"])) + request = _build_request(model=model) + provider = DummyProvider("openai", configured=False) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderNotConfiguredError): + service.generate(request) + + +def test_generate_validates_mode_support() -> None: + model = _build_model(ExternalModelCapabilities(modes=["txt2img"])) + request = _build_request(model=model, mode="img2img", init_image=_make_image()) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="Mode 'img2img'"): + service.generate(request) + + +def test_generate_validates_negative_prompt_support() -> None: + model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_negative_prompt=False)) + request = _build_request(model=model, negative_prompt="bad") + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="Negative prompts"): + service.generate(request) + + +def test_generate_requires_init_image_for_img2img() -> None: + model = _build_model(ExternalModelCapabilities(modes=["img2img"])) + request = _build_request(model=model, mode="img2img") + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="requires an init image"): + service.generate(request) + + +def test_generate_requires_mask_for_inpaint() -> None: + model = _build_model(ExternalModelCapabilities(modes=["inpaint"])) + request = _build_request(model=model, mode="inpaint", init_image=_make_image()) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="requires a mask"): + service.generate(request) + + +def test_generate_validates_reference_images() -> None: + model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_reference_images=False)) + request = _build_request( + model=model, + reference_images=[ExternalReferenceImage(image=_make_image(), weight=0.8)], + ) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="Reference images"): + service.generate(request) + + +def test_generate_validates_limits() -> None: + model = _build_model( + ExternalModelCapabilities( + modes=["txt2img"], + supports_reference_images=True, + max_reference_images=1, + max_images_per_request=1, + ) + ) + request = _build_request( + model=model, + num_images=2, + reference_images=[ + ExternalReferenceImage(image=_make_image()), + ExternalReferenceImage(image=_make_image()), + ], + ) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="supports at most"): + service.generate(request) + + +def test_generate_validates_allowed_aspect_ratios() -> None: + model = _build_model( + ExternalModelCapabilities( + modes=["txt2img"], + allowed_aspect_ratios=["1:1", "16:9"], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "16:9": ExternalImageSize(width=1344, height=768), + }, + ) + ) + request = _build_request(model=model) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + response = service.generate(request) + assert response.images == [] + assert provider.last_request is not None + assert provider.last_request.width == 1024 + assert provider.last_request.height == 1024 + + +def test_generate_validates_allowed_aspect_ratios_with_bucket_sizes() -> None: + model = _build_model( + ExternalModelCapabilities( + modes=["txt2img"], + allowed_aspect_ratios=["1:1", "16:9"], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "16:9": ExternalImageSize(width=1344, height=768), + }, + ) + ) + request = _build_request(model=model, width=160, height=90) + provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + response = service.generate(request) + + assert response.images == [] + assert provider.last_request is not None + assert provider.last_request.width == 1344 + assert provider.last_request.height == 768 + + +def test_generate_happy_path() -> None: + model = _build_model( + ExternalModelCapabilities(modes=["txt2img"], supports_negative_prompt=True, supports_seed=True) + ) + request = _build_request(model=model, negative_prompt="", seed=42) + result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=_make_image(), seed=42)]) + provider = DummyProvider("openai", configured=True, result=result) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + response = service.generate(request) + + assert response is result + assert provider.last_request == request diff --git a/tests/app/services/external_generation/test_external_provider_adapters.py b/tests/app/services/external_generation/test_external_provider_adapters.py new file mode 100644 index 00000000000..38f4c9e3d52 --- /dev/null +++ b/tests/app/services/external_generation/test_external_provider_adapters.py @@ -0,0 +1,346 @@ +import io +import logging + +import pytest +from PIL import Image + +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.external_generation.errors import ExternalProviderRequestError +from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGenerationRequest, + ExternalReferenceImage, +) +from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64 +from invokeai.app.services.external_generation.providers.gemini import GeminiProvider +from invokeai.app.services.external_generation.providers.openai import OpenAIProvider +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities + + +class DummyResponse: + def __init__(self, ok: bool, status_code: int = 200, json_data: dict | None = None, text: str = "") -> None: + self.ok = ok + self.status_code = status_code + self._json_data = json_data or {} + self.text = text + self.headers: dict[str, str] = {} + + def json(self) -> dict: + return self._json_data + + +def _make_image(color: str = "black") -> Image.Image: + return Image.new("RGB", (32, 32), color=color) + + +def _build_model(provider_id: str, provider_model_id: str) -> ExternalApiModelConfig: + return ExternalApiModelConfig( + key=f"{provider_id}_test", + name=f"{provider_id.title()} Test", + provider_id=provider_id, + provider_model_id=provider_model_id, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img", "inpaint"], + supports_negative_prompt=True, + supports_reference_images=True, + supports_seed=True, + supports_guidance=True, + ), + ) + + +def _build_request( + model: ExternalApiModelConfig, + mode: str = "txt2img", + init_image: Image.Image | None = None, + mask_image: Image.Image | None = None, + reference_images: list[ExternalReferenceImage] | None = None, +) -> ExternalGenerationRequest: + return ExternalGenerationRequest( + model=model, + mode=mode, # type: ignore[arg-type] + prompt="A test prompt", + negative_prompt="", + seed=123, + num_images=1, + width=256, + height=256, + steps=20, + guidance=5.5, + init_image=init_image, + mask_image=mask_image, + reference_images=reference_images or [], + metadata=None, + ) + + +def test_gemini_generate_success(monkeypatch: pytest.MonkeyPatch) -> None: + api_key = "gemini-key" + config = InvokeAIAppConfig(external_gemini_api_key=api_key) + provider = GeminiProvider(config, logging.getLogger("test")) + model = _build_model("gemini", "gemini-2.5-flash-image") + init_image = _make_image("blue") + ref_image = _make_image("red") + request = _build_request( + model, + init_image=init_image, + reference_images=[ExternalReferenceImage(image=ref_image, weight=0.6)], + ) + encoded = encode_image_base64(_make_image("green")) + captured: dict[str, object] = {} + + def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + captured["params"] = params + captured["json"] = json + captured["timeout"] = timeout + return DummyResponse( + ok=True, + json_data={ + "candidates": [ + {"content": {"parts": [{"inlineData": {"data": encoded}}]}}, + ] + }, + ) + + monkeypatch.setattr("requests.post", fake_post) + + result = provider.generate(request) + + assert ( + captured["url"] + == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent" + ) + assert captured["params"] == {"key": api_key} + payload = captured["json"] + assert isinstance(payload, dict) + system_instruction = payload.get("systemInstruction") + assert isinstance(system_instruction, dict) + system_parts = system_instruction.get("parts") + assert isinstance(system_parts, list) + system_text = str(system_parts[0]).lower() + assert "image" in system_text + generation_config = payload.get("generationConfig") + assert isinstance(generation_config, dict) + assert generation_config["candidateCount"] == 1 + assert generation_config["responseModalities"] == ["IMAGE"] + contents = payload.get("contents") + assert isinstance(contents, list) + first_content = contents[0] + assert isinstance(first_content, dict) + parts = first_content.get("parts") + assert isinstance(parts, list) + assert len(parts) >= 3 + part0 = parts[0] + part1 = parts[1] + part2 = parts[2] + assert isinstance(part0, dict) + assert isinstance(part1, dict) + assert isinstance(part2, dict) + inline0 = part0.get("inlineData") + assert isinstance(inline0, dict) + assert part1["text"] == request.prompt + inline1 = part2.get("inlineData") + assert isinstance(inline1, dict) + assert inline0["data"] == encode_image_base64(init_image) + assert inline1["data"] == encode_image_base64(ref_image) + assert result.images[0].seed == request.seed + assert result.provider_metadata == {"model": request.model.provider_model_id} + + +def test_gemini_generate_error_response(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_gemini_api_key="gemini-key") + provider = GeminiProvider(config, logging.getLogger("test")) + model = _build_model("gemini", "gemini-2.5-flash-image") + request = _build_request(model) + + def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse: + return DummyResponse(ok=False, status_code=400, text="bad request") + + monkeypatch.setattr("requests.post", fake_post) + + with pytest.raises(ExternalProviderRequestError, match="Gemini request failed"): + provider.generate(request) + + +def test_gemini_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig( + external_gemini_api_key="gemini-key", + external_gemini_base_url="https://proxy.gemini", + ) + provider = GeminiProvider(config, logging.getLogger("test")) + model = _build_model("gemini", "gemini-2.5-flash-image") + request = _build_request(model) + encoded = encode_image_base64(_make_image("green")) + captured: dict[str, object] = {} + + def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + return DummyResponse( + ok=True, + json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]}, + ) + + monkeypatch.setattr("requests.post", fake_post) + + provider.generate(request) + + assert captured["url"] == "https://proxy.gemini/v1beta/models/gemini-2.5-flash-image:generateContent" + + +def test_gemini_generate_keeps_base_url_version(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig( + external_gemini_api_key="gemini-key", + external_gemini_base_url="https://proxy.gemini/v1", + ) + provider = GeminiProvider(config, logging.getLogger("test")) + model = _build_model("gemini", "gemini-2.5-flash-image") + request = _build_request(model) + encoded = encode_image_base64(_make_image("green")) + captured: dict[str, object] = {} + + def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + return DummyResponse( + ok=True, + json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]}, + ) + + monkeypatch.setattr("requests.post", fake_post) + + provider.generate(request) + + assert captured["url"] == "https://proxy.gemini/v1/models/gemini-2.5-flash-image:generateContent" + + +def test_gemini_generate_strips_models_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_gemini_api_key="gemini-key") + provider = GeminiProvider(config, logging.getLogger("test")) + model = _build_model("gemini", "models/gemini-2.5-flash-image") + request = _build_request(model) + encoded = encode_image_base64(_make_image("green")) + captured: dict[str, object] = {} + + def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + return DummyResponse( + ok=True, + json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]}, + ) + + monkeypatch.setattr("requests.post", fake_post) + + provider.generate(request) + + assert ( + captured["url"] + == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent" + ) + + +def test_openai_generate_txt2img_success(monkeypatch: pytest.MonkeyPatch) -> None: + api_key = "openai-key" + config = InvokeAIAppConfig(external_openai_api_key=api_key) + provider = OpenAIProvider(config, logging.getLogger("test")) + model = _build_model("openai", "gpt-image-1") + request = _build_request(model) + encoded = encode_image_base64(_make_image("purple")) + captured: dict[str, object] = {} + + def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]}) + response.headers["x-request-id"] = "req-123" + return response + + monkeypatch.setattr("requests.post", fake_post) + + result = provider.generate(request) + + assert captured["url"] == "https://api.openai.com/v1/images/generations" + headers = captured["headers"] + assert isinstance(headers, dict) + assert headers["Authorization"] == f"Bearer {api_key}" + json_payload = captured["json"] + assert isinstance(json_payload, dict) + assert json_payload["prompt"] == request.prompt + assert result.provider_request_id == "req-123" + assert result.images[0].seed == request.seed + assert decode_image_base64(encoded).size == result.images[0].image.size + + +def test_openai_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig( + external_openai_api_key="openai-key", + external_openai_base_url="https://proxy.openai/", + ) + provider = OpenAIProvider(config, logging.getLogger("test")) + model = _build_model("openai", "gpt-image-1") + request = _build_request(model) + encoded = encode_image_base64(_make_image("purple")) + captured: dict[str, object] = {} + + def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse: + captured["url"] = url + return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]}) + + monkeypatch.setattr("requests.post", fake_post) + + provider.generate(request) + + assert captured["url"] == "https://proxy.openai/v1/images/generations" + + +def test_openai_generate_txt2img_error_response(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_openai_api_key="openai-key") + provider = OpenAIProvider(config, logging.getLogger("test")) + model = _build_model("openai", "gpt-image-1") + request = _build_request(model) + + def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse: + return DummyResponse(ok=False, status_code=500, text="server error") + + monkeypatch.setattr("requests.post", fake_post) + + with pytest.raises(ExternalProviderRequestError, match="OpenAI request failed"): + provider.generate(request) + + +def test_openai_generate_inpaint_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_openai_api_key="openai-key") + provider = OpenAIProvider(config, logging.getLogger("test")) + model = _build_model("openai", "gpt-image-1") + request = _build_request( + model, + mode="inpaint", + init_image=_make_image("white"), + mask_image=_make_image("black"), + ) + encoded = encode_image_base64(_make_image("orange")) + captured: dict[str, object] = {} + + def fake_post(url: str, headers: dict, data: dict, files: dict, timeout: int) -> DummyResponse: + captured["url"] = url + captured["data"] = data + captured["files"] = files + response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]}) + return response + + monkeypatch.setattr("requests.post", fake_post) + + result = provider.generate(request) + + assert captured["url"] == "https://api.openai.com/v1/images/edits" + data_payload = captured["data"] + assert isinstance(data_payload, dict) + assert data_payload["prompt"] == request.prompt + files = captured["files"] + assert isinstance(files, dict) + assert "image" in files + assert "mask" in files + image_tuple = files["image"] + assert isinstance(image_tuple, tuple) + assert image_tuple[0] == "image.png" + assert isinstance(image_tuple[1], io.BytesIO) + assert result.images diff --git a/tests/app/services/model_install/test_model_install.py b/tests/app/services/model_install/test_model_install.py index d19eb95a8c2..c3d5d18e06c 100644 --- a/tests/app/services/model_install/test_model_install.py +++ b/tests/app/services/model_install/test_model_install.py @@ -33,6 +33,7 @@ URLModelSource, ) from invokeai.app.services.model_records import ModelRecordChanges, UnknownModelException +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig from invokeai.backend.model_manager.taxonomy import ( BaseModelType, ModelFormat, @@ -213,6 +214,21 @@ def test_inplace_install( assert Path(job.config_out.path).exists() +def test_external_install(mm2_installer: ModelInstallServiceBase) -> None: + config = ModelRecordChanges(name="ChatGPT Image", description="External model", key="chatgpt_image") + job = mm2_installer.heuristic_import("external://openai/gpt-image-1", config=config) + + mm2_installer.wait_for_installs() + + assert job.status == InstallStatus.COMPLETED + assert job.config_out is not None + assert isinstance(job.config_out, ExternalApiModelConfig) + assert job.config_out.provider_id == "openai" + assert job.config_out.provider_model_id == "gpt-image-1" + assert job.config_out.base == BaseModelType.External + assert job.config_out.type == ModelType.ExternalImageGenerator + + def test_delete_install( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: diff --git a/tests/app/services/model_load/test_load_api.py b/tests/app/services/model_load/test_load_api.py index c0760cd3cad..8f7f8449723 100644 --- a/tests/app/services/model_load/test_load_api.py +++ b/tests/app/services/model_load/test_load_api.py @@ -4,6 +4,7 @@ import torch from diffusers import AutoencoderTiny +from invokeai.app.invocations.model import ModelIdentifierField from invokeai.app.services.invocation_services import InvocationServices from invokeai.app.services.model_manager import ModelManagerServiceBase from invokeai.app.services.shared.invocation_context import ( @@ -11,6 +12,7 @@ InvocationContextData, build_invocation_context, ) +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities from invokeai.backend.model_manager.load.load_base import LoadedModelWithoutConfig from tests.backend.model_manager.model_manager_fixtures import * # noqa F403 @@ -78,6 +80,27 @@ def test_download_and_load(mock_context: InvocationContext) -> None: assert loaded_model_1.model is loaded_model_2.model # should be cached copy +def test_external_model_load_raises( + mock_context: InvocationContext, mm2_model_manager: ModelManagerServiceBase +) -> None: + config = ExternalApiModelConfig( + key="external_test", + name="External Test", + provider_id="openai", + provider_model_id="gpt-image-1", + capabilities=ExternalModelCapabilities(modes=["txt2img"]), + ) + mm2_model_manager.store.add_model(config) + + model_field = ModelIdentifierField.from_config(config) + + with pytest.raises(ValueError, match="External API models"): + mock_context.models.load(model_field) + + with pytest.raises(ValueError, match="External API models"): + mock_context.models.load_by_attrs(name=config.name, base=config.base, type=config.type) + + def test_download_diffusers(mock_context: InvocationContext) -> None: model_path = mock_context.models.download_and_cache_model("stabilityai/sdxl-turbo") assert (model_path / "model_index.json").exists() diff --git a/tests/backend/model_manager/test_external_api_config.py b/tests/backend/model_manager/test_external_api_config.py new file mode 100644 index 00000000000..943a5a79918 --- /dev/null +++ b/tests/backend/model_manager/test_external_api_config.py @@ -0,0 +1,54 @@ +import pytest +from pydantic import ValidationError + +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelConfig, + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, +) + + +def test_external_api_model_config_defaults() -> None: + capabilities = ExternalModelCapabilities(modes=["txt2img"], supports_seed=True) + + config = ExternalApiModelConfig( + name="Test External", + provider_id="openai", + provider_model_id="gpt-image-1", + capabilities=capabilities, + ) + + assert config.path == "external://openai/gpt-image-1" + assert config.source == "external://openai/gpt-image-1" + assert config.hash == "external:openai:gpt-image-1" + assert config.file_size == 0 + assert config.default_settings is None + assert config.capabilities.supports_seed is True + + +def test_external_api_model_capabilities_allows_aspect_ratio_sizes() -> None: + capabilities = ExternalModelCapabilities( + modes=["txt2img"], + allowed_aspect_ratios=["1:1"], + aspect_ratio_sizes={"1:1": ExternalImageSize(width=1024, height=1024)}, + ) + + assert capabilities.aspect_ratio_sizes is not None + assert capabilities.aspect_ratio_sizes["1:1"].width == 1024 + + +def test_external_api_model_config_rejects_extra_fields() -> None: + with pytest.raises(ValidationError): + ExternalModelCapabilities(modes=["txt2img"], supports_seed=True, extra_field=True) # type: ignore + + with pytest.raises(ValidationError): + ExternalApiModelDefaultSettings(width=512, extra_field=True) # type: ignore + + +def test_external_api_model_config_validates_limits() -> None: + with pytest.raises(ValidationError): + ExternalModelCapabilities(modes=["txt2img"], max_images_per_request=0) + + with pytest.raises(ValidationError): + ExternalApiModelDefaultSettings(width=0) diff --git a/tests/conftest.py b/tests/conftest.py index 980a99611ab..bfd9f070df4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage +from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService from invokeai.app.services.images.images_default import ImageService from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache from invokeai.app.services.invocation_services import InvocationServices @@ -52,6 +53,7 @@ def mock_services() -> InvocationServices: model_images=None, # type: ignore model_manager=None, # type: ignore download_queue=None, # type: ignore + external_generation=ExternalGenerationService({}, logger), names=None, # type: ignore performance_statistics=InvocationStatsService(), session_processor=None, # type: ignore From 74ecc461b979fb9c212586a307f2c164b4573e15 Mon Sep 17 00:00:00 2001 From: CypherNaught-0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:13:50 +0100 Subject: [PATCH 02/13] feat: support reference images for external models --- .../external_generation_default.py | 35 ++++++++++- .../external_generation/providers/openai.py | 31 ++++++--- .../backend/model_manager/starter_models.py | 2 +- .../components/RefImage/RefImagePreview.tsx | 12 ++-- .../components/RefImage/RefImageSettings.tsx | 13 ++-- .../controlLayers/store/validators.ts | 10 ++- .../test_external_generation_service.py | 23 +++++++ .../test_external_provider_adapters.py | 63 +++++++++++++++++-- 8 files changed, 160 insertions(+), 29 deletions(-) diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py index c72e16cde8d..c96b5af711e 100644 --- a/invokeai/app/services/external_generation/external_generation_default.py +++ b/invokeai/app/services/external_generation/external_generation_default.py @@ -15,6 +15,7 @@ ExternalProvider, ) from invokeai.app.services.external_generation.external_generation_common import ( + ExternalGeneratedImage, ExternalGenerationRequest, ExternalGenerationResult, ExternalProviderStatus, @@ -37,10 +38,17 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu raise ExternalProviderNotConfiguredError(f"Provider '{request.model.provider_id}' is missing credentials") request = self._refresh_model_capabilities(request) + resize_to_original_inpaint_size = _get_resize_target_for_inpaint(request) request = self._bucket_request(request) self._validate_request(request) - return provider.generate(request) + result = provider.generate(request) + + if resize_to_original_inpaint_size is None: + return result + + width, height = resize_to_original_inpaint_size + return _resize_result_images(result, width, height) def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]: return {provider_id: provider.get_status() for provider_id, provider in self._providers.items()} @@ -276,6 +284,31 @@ def _resize_image(image: PILImageType | None, width: int, height: int, mode: str return image.convert(mode).resize((width, height), Image.Resampling.LANCZOS) +def _get_resize_target_for_inpaint(request: ExternalGenerationRequest) -> tuple[int, int] | None: + if request.mode != "inpaint" or request.init_image is None: + return None + return request.init_image.width, request.init_image.height + + +def _resize_result_images(result: ExternalGenerationResult, width: int, height: int) -> ExternalGenerationResult: + resized_images = [ + ExternalGeneratedImage( + image=generated.image + if generated.image.width == width and generated.image.height == height + else generated.image.resize((width, height), Image.Resampling.LANCZOS), + seed=generated.seed, + ) + for generated in result.images + ] + return ExternalGenerationResult( + images=resized_images, + seed_used=result.seed_used, + provider_request_id=result.provider_request_id, + provider_metadata=result.provider_metadata, + content_filters=result.content_filters, + ) + + def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig: source = model.source or f"external://{model.provider_id}/{model.provider_model_id}" starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) diff --git a/invokeai/app/services/external_generation/providers/openai.py b/invokeai/app/services/external_generation/providers/openai.py index e31a493b7a1..f06491a225b 100644 --- a/invokeai/app/services/external_generation/providers/openai.py +++ b/invokeai/app/services/external_generation/providers/openai.py @@ -3,6 +3,7 @@ import io import requests +from PIL.Image import Image as PILImageType from invokeai.app.services.external_generation.errors import ExternalProviderRequestError from invokeai.app.services.external_generation.external_generation_base import ExternalProvider @@ -29,7 +30,9 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu base_url = (self._app_config.external_openai_base_url or "https://api.openai.com").rstrip("/") headers = {"Authorization": f"Bearer {api_key}"} - if request.mode == "txt2img": + use_edits_endpoint = request.mode != "txt2img" or bool(request.reference_images) + + if not use_edits_endpoint: payload: dict[str, object] = { "prompt": request.prompt, "n": request.num_images, @@ -45,20 +48,28 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu timeout=120, ) else: - files: dict[str, tuple[str, io.BytesIO, str]] = {} - if request.init_image is None: - raise ExternalProviderRequestError("OpenAI img2img/inpaint requires an init image") - - image_buffer = io.BytesIO() - request.init_image.save(image_buffer, format="PNG") - image_buffer.seek(0) - files["image"] = ("image.png", image_buffer, "image/png") + images: list[PILImageType] = [] + if request.init_image is not None: + images.append(request.init_image) + images.extend(reference.image for reference in request.reference_images) + if not images: + raise ExternalProviderRequestError( + "OpenAI image edits require at least one image (init image or reference image)" + ) + + files: list[tuple[str, tuple[str, io.BytesIO, str]]] = [] + image_field_name = "image" if len(images) == 1 else "image[]" + for index, image in enumerate(images): + image_buffer = io.BytesIO() + image.save(image_buffer, format="PNG") + image_buffer.seek(0) + files.append((image_field_name, (f"image_{index}.png", image_buffer, "image/png"))) if request.mask_image is not None: mask_buffer = io.BytesIO() request.mask_image.save(mask_buffer, format="PNG") mask_buffer.seek(0) - files["mask"] = ("mask.png", mask_buffer, "image/png") + files.append(("mask", ("mask.png", mask_buffer, "image/png"))) data: dict[str, object] = { "prompt": request.prompt, diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py index 59d7ceba205..183edc04ba7 100644 --- a/invokeai/backend/model_manager/starter_models.py +++ b/invokeai/backend/model_manager/starter_models.py @@ -964,7 +964,7 @@ class StarterModelBundle(BaseModel): supports_negative_prompt=True, supports_seed=True, supports_guidance=True, - supports_reference_images=False, + supports_reference_images=True, max_images_per_request=1, ), default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx index 84c1b2fc37b..ddbdb8b131c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -15,6 +15,7 @@ import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/va import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi'; import { useImageDTOFromCroppableImage } from 'services/api/endpoints/images'; +import { isExternalApiModelConfig } from 'services/api/types'; import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent'; @@ -71,18 +72,19 @@ export const RefImagePreview = memo(() => { const selectedEntityId = useAppSelector(selectSelectedRefEntityId); const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen); const [showWeightDisplay, setShowWeightDisplay] = useState(false); + const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig); const imageDTO = useImageDTOFromCroppableImage(entity.config.image); const sx = useMemo(() => { - if (!isIPAdapterConfig(entity.config)) { + if (!isIPAdapterConfig(entity.config) || isExternalModel) { return baseSx; } return getImageSxWithWeight(entity.config.weight); - }, [entity.config]); + }, [entity.config, isExternalModel]); useEffect(() => { - if (!isIPAdapterConfig(entity.config)) { + if (!isIPAdapterConfig(entity.config) || isExternalModel) { return; } setShowWeightDisplay(true); @@ -92,7 +94,7 @@ export const RefImagePreview = memo(() => { return () => { window.clearTimeout(timeout); }; - }, [entity.config]); + }, [entity.config, isExternalModel]); const warnings = useMemo(() => { return getGlobalReferenceImageWarnings(entity, mainModelConfig); @@ -154,7 +156,7 @@ export const RefImagePreview = memo(() => { ) : ( )} - {isIPAdapterConfig(entity.config) && ( + {isIPAdapterConfig(entity.config) && !isExternalModel && ( { const selectConfig = useMemo(() => buildSelectConfig(id), [id]); const config = useAppSelector(selectConfig); const tab = useAppSelector(selectActiveTab); + const mainModelConfig = useAppSelector(selectMainModelConfig); const onChangeBeginEndStepPct = useCallback( (beginEndStepPct: [number, number]) => { @@ -120,9 +122,10 @@ const RefImageSettingsContent = memo(() => { ); const isFLUX = useAppSelector(selectIsFLUX); + const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig); - // FLUX.2 Klein has built-in reference image support - no model selector needed - const showModelSelector = !isFlux2ReferenceImageConfig(config); + // FLUX.2 Klein and external API models do not require a ref image model selection. + const showModelSelector = !isFlux2ReferenceImageConfig(config) && !isExternalModel; return ( @@ -150,14 +153,14 @@ const RefImageSettingsContent = memo(() => { )} - {isIPAdapterConfig(config) && ( + {isIPAdapterConfig(config) && !isExternalModel && ( {!isFLUX && } )} - {isFLUXReduxConfig(config) && ( + {isFLUXReduxConfig(config) && !isExternalModel && ( None: assert response is result assert provider.last_request == request + + +def test_generate_resizes_inpaint_result_to_original_init_size() -> None: + model = _build_model(ExternalModelCapabilities(modes=["inpaint"])) + request = _build_request( + model=model, + mode="inpaint", + width=128, + height=128, + init_image=_make_image(), + mask_image=_make_image(), + ) + generated_large = Image.new("RGB", (128, 128), color="black") + result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=generated_large, seed=1)]) + provider = DummyProvider("openai", configured=True, result=result) + service = ExternalGenerationService({"openai": provider}, logging.getLogger("test")) + + response = service.generate(request) + + assert request.init_image is not None + assert response.images[0].image.width == request.init_image.width + assert response.images[0].image.height == request.init_image.height + assert response.images[0].seed == 1 diff --git a/tests/app/services/external_generation/test_external_provider_adapters.py b/tests/app/services/external_generation/test_external_provider_adapters.py index 38f4c9e3d52..c4da4c913be 100644 --- a/tests/app/services/external_generation/test_external_provider_adapters.py +++ b/tests/app/services/external_generation/test_external_provider_adapters.py @@ -320,7 +320,13 @@ def test_openai_generate_inpaint_uses_edit_endpoint(monkeypatch: pytest.MonkeyPa encoded = encode_image_base64(_make_image("orange")) captured: dict[str, object] = {} - def fake_post(url: str, headers: dict, data: dict, files: dict, timeout: int) -> DummyResponse: + def fake_post( + url: str, + headers: dict, + data: dict, + files: list[tuple[str, tuple[str, io.BytesIO, str]]], + timeout: int, + ) -> DummyResponse: captured["url"] = url captured["data"] = data captured["files"] = files @@ -336,11 +342,56 @@ def fake_post(url: str, headers: dict, data: dict, files: dict, timeout: int) -> assert isinstance(data_payload, dict) assert data_payload["prompt"] == request.prompt files = captured["files"] - assert isinstance(files, dict) - assert "image" in files - assert "mask" in files - image_tuple = files["image"] + assert isinstance(files, list) + image_file = next((file for file in files if file[0] == "image"), None) + mask_file = next((file for file in files if file[0] == "mask"), None) + assert image_file is not None + assert mask_file is not None + image_tuple = image_file[1] assert isinstance(image_tuple, tuple) - assert image_tuple[0] == "image.png" + assert image_tuple[0] == "image_0.png" assert isinstance(image_tuple[1], io.BytesIO) assert result.images + + +def test_openai_generate_txt2img_with_references_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_openai_api_key="openai-key") + provider = OpenAIProvider(config, logging.getLogger("test")) + model = _build_model("openai", "gpt-image-1") + request = _build_request( + model, + reference_images=[ + ExternalReferenceImage(image=_make_image("red")), + ExternalReferenceImage(image=_make_image("blue")), + ], + ) + encoded = encode_image_base64(_make_image("orange")) + captured: dict[str, object] = {} + + def fake_post( + url: str, + headers: dict, + data: dict, + files: list[tuple[str, tuple[str, io.BytesIO, str]]], + timeout: int, + ) -> DummyResponse: + captured["url"] = url + captured["data"] = data + captured["files"] = files + return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]}) + + monkeypatch.setattr("requests.post", fake_post) + + result = provider.generate(request) + + assert captured["url"] == "https://api.openai.com/v1/images/edits" + data_payload = captured["data"] + assert isinstance(data_payload, dict) + assert data_payload["prompt"] == request.prompt + files = captured["files"] + assert isinstance(files, list) + image_files = [file for file in files if file[0] == "image[]"] + assert len(image_files) == 2 + assert image_files[0][1][0] == "image_0.png" + assert image_files[1][1][0] == "image_1.png" + assert result.images From a9d3b4e17c2440c89b629d1089872fe3ed0edb61 Mon Sep 17 00:00:00 2001 From: CypherNaught-0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:23:16 +0100 Subject: [PATCH 03/13] fix: sorting lint error --- .../features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index c54523e0fad..a35d865e752 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -11,10 +11,10 @@ import { filesize } from 'filesize'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { - isExternalApiModelConfig, type AnyModelConfig, type CLIPEmbedModelConfig, type CLIPVisionModelConfig, + isExternalApiModelConfig, type LlavaOnevisionModelConfig, type Qwen3EncoderModelConfig, type SigLIPModelConfig, From 1b43769b951b1244604d391df198ce04e5b5a238 Mon Sep 17 00:00:00 2001 From: CypherNaught-0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:12:35 +0100 Subject: [PATCH 04/13] chore: hide Reidentify button for external models --- .../subpanels/ModelPanel/ModelReidentifyButton.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelReidentifyButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelReidentifyButton.tsx index 31334c0510d..8ea5df310a7 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelReidentifyButton.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelReidentifyButton.tsx @@ -4,7 +4,9 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiSparkleFill } from 'react-icons/pi'; import { useReidentifyModelMutation } from 'services/api/endpoints/models'; -import type { AnyModelConfig } from 'services/api/types'; +import { type AnyModelConfig, isExternalApiModelConfig } from 'services/api/types'; + +import { isExternalModel } from './isExternalModel'; interface Props { modelConfig: AnyModelConfig; @@ -13,6 +15,7 @@ interface Props { export const ModelReidentifyButton = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); const [reidentifyModel, { isLoading }] = useReidentifyModelMutation(); + const isExternal = isExternalApiModelConfig(modelConfig) || isExternalModel(modelConfig.path); const onClick = useCallback(() => { reidentifyModel({ key: modelConfig.key }) @@ -40,6 +43,10 @@ export const ModelReidentifyButton = memo(({ modelConfig }: Props) => { }); }, [modelConfig.key, reidentifyModel, t]); + if (isExternal) { + return null; + } + return (