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/Dimensions/DimensionsAspectRatioSelect.test.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.test.tsx
new file mode 100644
index 00000000000..636260d1d25
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.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', () => {
+ 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/Dimensions/DimensionsAspectRatioSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
index 4d3edc6e4bd..5e2952552c9 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
@@ -1,7 +1,11 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { aspectRatioIdChanged, selectAspectRatioID } from 'features/controlLayers/store/paramsSlice';
+import {
+ aspectRatioIdChanged,
+ selectAllowedAspectRatioIDs,
+ selectAspectRatioID,
+} from 'features/controlLayers/store/paramsSlice';
import { isAspectRatioID, zAspectRatioID } from 'features/controlLayers/store/types';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback } from 'react';
@@ -12,6 +16,8 @@ export const DimensionsAspectRatioSelect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const id = useAppSelector(selectAspectRatioID);
+ const allowedAspectRatios = useAppSelector(selectAllowedAspectRatioIDs);
+ const options = allowedAspectRatios ?? zAspectRatioID.options;
const onChange = useCallback>(
(e) => {
@@ -29,7 +35,7 @@ export const DimensionsAspectRatioSelect = 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 (