Remove unused external model fields and add provider-specific parameters

- Remove negative_prompt, steps, guidance, reference_image_weights,
  reference_image_modes from external model nodes (unused by any provider)
- Remove supports_negative_prompt, supports_steps, supports_guidance
  from ExternalModelCapabilities
- Add provider_options dict to ExternalGenerationRequest for
  provider-specific parameters
- Add OpenAI-specific fields: quality, background, input_fidelity
- Add Gemini-specific fields: temperature, thinking_level
- Add new OpenAI starter models: GPT Image 1.5, GPT Image 1 Mini,
  DALL-E 3, DALL-E 2
- Fix OpenAI provider to use output_format (GPT Image) vs
  response_format (DALL-E) and send model ID in requests
- Add fixed aspect ratio sizes for OpenAI models (bucketing)
- Add ExternalProviderRateLimitError with retry logic for 429 responses
- Add provider-specific UI components in ExternalSettingsAccordion
- Simplify ParamSteps/ParamGuidance by removing dead external overrides
- Update all backend and frontend tests
This commit is contained in:
Alexander Eichhorn
2026-03-20 08:17:16 +01:00
parent 8375f95ea9
commit d8d0ebc356
28 changed files with 530 additions and 303 deletions

View File

@@ -1,4 +1,4 @@
from typing import Any, ClassVar
from typing import Any, ClassVar, Literal
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
from invokeai.app.invocations.fields import (
@@ -34,19 +34,18 @@ class BaseExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBo
)
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)
image_size: str | None = InputField(default=None, description="Image size preset (e.g. 1K, 2K, 4K)")
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 _build_provider_options(self) -> dict[str, Any] | None:
"""Override in provider-specific subclasses to pass extra options."""
return None
def invoke(self, context: InvocationContext) -> ImageCollectionOutput:
model_config = context.models.get_config(self.model)
@@ -66,39 +65,25 @@ class BaseExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBo
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):
for image_field in 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))
reference_images.append(ExternalReferenceImage(image=reference_image))
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,
image_size=self.image_size,
steps=self.steps,
guidance=self.guidance,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images,
metadata=self._build_request_metadata(),
provider_options=self._build_provider_options(),
)
result = context._services.external_generation.generate(request)
@@ -174,6 +159,23 @@ class OpenAIImageGenerationInvocation(BaseExternalImageGenerationInvocation):
provider_id = "openai"
quality: Literal["auto", "high", "medium", "low"] = InputField(default="auto", description="Output image quality")
background: Literal["auto", "transparent", "opaque"] = InputField(
default="auto", description="Background transparency handling"
)
input_fidelity: Literal["low", "high"] | None = InputField(
default=None, description="Fidelity to source images (edits only)"
)
def _build_provider_options(self) -> dict[str, Any]:
options: dict[str, Any] = {
"quality": self.quality,
"background": self.background,
}
if self.input_fidelity is not None:
options["input_fidelity"] = self.input_fidelity
return options
@invocation(
"gemini_image_generation",
@@ -186,3 +188,16 @@ class GeminiImageGenerationInvocation(BaseExternalImageGenerationInvocation):
"""Generate images using a Gemini-hosted external model."""
provider_id = "gemini"
temperature: float | None = InputField(default=None, ge=0.0, le=2.0, description="Sampling temperature")
thinking_level: Literal["minimal", "high"] | None = InputField(
default=None, description="Thinking level for image generation"
)
def _build_provider_options(self) -> dict[str, Any] | None:
options: dict[str, Any] = {}
if self.temperature is not None:
options["temperature"] = self.temperature
if self.thinking_level is not None:
options["thinking_level"] = self.thinking_level
return options or None

View File

@@ -16,3 +16,13 @@ class ExternalProviderCapabilityError(ExternalGenerationError):
class ExternalProviderRequestError(ExternalGenerationError):
"""Raised when a provider rejects the request or returns an error."""
class ExternalProviderRateLimitError(ExternalProviderRequestError):
"""Raised when a provider returns HTTP 429 (rate limit exceeded)."""
retry_after: float | None
def __init__(self, message: str, retry_after: float | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after

View File

@@ -11,8 +11,6 @@ from invokeai.backend.model_manager.configs.external_api import ExternalApiModel
@dataclass(frozen=True)
class ExternalReferenceImage:
image: PILImageType
weight: float | None = None
mode: str | None = None
@dataclass(frozen=True)
@@ -20,18 +18,16 @@ class ExternalGenerationRequest:
model: ExternalApiModelConfig
mode: ExternalGenerationMode
prompt: str
negative_prompt: str | None
seed: int | None
num_images: int
width: int
height: int
image_size: str | None
steps: int | None
guidance: float | None
init_image: PILImageType | None
mask_image: PILImageType | None
reference_images: list[ExternalReferenceImage]
metadata: dict[str, Any] | None
provider_options: dict[str, Any] | None = None
@dataclass(frozen=True)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import time
from logging import Logger
from typing import TYPE_CHECKING
@@ -10,6 +11,7 @@ from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderNotConfiguredError,
ExternalProviderNotFoundError,
ExternalProviderRateLimitError,
)
from invokeai.app.services.external_generation.external_generation_base import (
ExternalGenerationServiceBase,
@@ -52,7 +54,7 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
request = self._bucket_request(request)
self._validate_request(request)
result = provider.generate(request)
result = self._generate_with_retry(provider, request)
if resize_to_original_inpaint_size is None:
return result
@@ -60,6 +62,30 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
width, height = resize_to_original_inpaint_size
return _resize_result_images(result, width, height)
_MAX_RETRIES = 3
_DEFAULT_RETRY_DELAY = 10.0
_MAX_RETRY_DELAY = 60.0
def _generate_with_retry(
self, provider: ExternalProvider, request: ExternalGenerationRequest
) -> ExternalGenerationResult:
for attempt in range(self._MAX_RETRIES):
try:
return provider.generate(request)
except ExternalProviderRateLimitError as exc:
if attempt == self._MAX_RETRIES - 1:
raise
delay = min(exc.retry_after or self._DEFAULT_RETRY_DELAY, self._MAX_RETRY_DELAY)
self._logger.warning(
"Rate limited by %s (attempt %d/%d), retrying in %.0fs",
request.model.provider_id,
attempt + 1,
self._MAX_RETRIES,
delay,
)
time.sleep(delay)
raise ExternalProviderRateLimitError("Rate limit exceeded after all retries")
def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]:
return {provider_id: provider.get_status() for provider_id, provider in self._providers.items()}
@@ -77,15 +103,9 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
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}")
@@ -159,18 +179,16 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
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,
image_size=request.image_size,
steps=request.steps,
guidance=request.guidance,
init_image=request.init_image,
mask_image=request.mask_image,
reference_images=request.reference_images,
metadata=request.metadata,
provider_options=request.provider_options,
)
def _bucket_request(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
@@ -230,18 +248,16 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
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,
image_size=request.image_size,
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,
provider_options=request.provider_options,
)

View File

@@ -6,7 +6,7 @@ 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.errors import ExternalProviderRateLimitError, ExternalProviderRequestError
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
@@ -64,10 +64,14 @@ class GeminiProvider(ExternalProvider):
}
)
opts = request.provider_options or {}
generation_config: dict[str, object] = {
"candidateCount": request.num_images,
"responseModalities": ["IMAGE"],
}
if "temperature" in opts:
generation_config["temperature"] = opts["temperature"]
aspect_ratio = _select_aspect_ratio(
request.width,
request.height,
@@ -97,6 +101,8 @@ class GeminiProvider(ExternalProvider):
"contents": [{"role": "user", "parts": request_parts}],
"generationConfig": generation_config,
}
if "thinking_level" in opts:
payload["thinkingConfig"] = {"thinkingLevel": opts["thinking_level"].upper()}
self._dump_debug_payload("request", payload)
@@ -108,6 +114,12 @@ class GeminiProvider(ExternalProvider):
)
if not response.ok:
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("retry-after"))
raise ExternalProviderRateLimitError(
f"Gemini rate limit exceeded. {f'Retry after {retry_after:.0f}s.' if retry_after else 'Please try again later.'}",
retry_after=retry_after,
)
raise ExternalProviderRequestError(
f"Gemini request failed with status {response.status_code} for model '{model_id}': {response.text}"
)
@@ -252,6 +264,15 @@ def _parse_ratio(value: str) -> float | None:
return numerator / denominator
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b

View File

@@ -5,7 +5,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.errors import ExternalProviderRateLimitError, ExternalProviderRequestError
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
@@ -18,6 +18,8 @@ from invokeai.app.services.external_generation.image_utils import decode_image_b
class OpenAIProvider(ExternalProvider):
provider_id = "openai"
_GPT_IMAGE_MODELS = {"gpt-image-1", "gpt-image-1.5", "gpt-image-1-mini"}
def is_configured(self) -> bool:
return bool(self._app_config.external_openai_api_key)
@@ -26,21 +28,33 @@ class OpenAIProvider(ExternalProvider):
if not api_key:
raise ExternalProviderRequestError("OpenAI API key is not configured")
model_id = request.model.provider_model_id
is_gpt_image = model_id in self._GPT_IMAGE_MODELS
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}"}
use_edits_endpoint = request.mode != "txt2img" or bool(request.reference_images)
opts = request.provider_options or {}
if not use_edits_endpoint:
payload: dict[str, object] = {
"model": model_id,
"prompt": request.prompt,
"n": request.num_images,
"size": size,
"response_format": "b64_json",
}
if request.seed is not None:
payload["seed"] = request.seed
# GPT Image models use output_format; DALL-E uses response_format
if is_gpt_image:
payload["output_format"] = "png"
else:
payload["response_format"] = "b64_json"
if is_gpt_image:
if opts.get("quality") and opts["quality"] != "auto":
payload["quality"] = opts["quality"]
if opts.get("background") and opts["background"] != "auto":
payload["background"] = opts["background"]
response = requests.post(
f"{base_url}/v1/images/generations",
headers=headers,
@@ -72,11 +86,22 @@ class OpenAIProvider(ExternalProvider):
files.append(("mask", ("mask.png", mask_buffer, "image/png")))
data: dict[str, object] = {
"model": model_id,
"prompt": request.prompt,
"n": request.num_images,
"size": size,
"response_format": "b64_json",
}
if is_gpt_image:
data["output_format"] = "png"
else:
data["response_format"] = "b64_json"
if is_gpt_image:
if opts.get("quality") and opts["quality"] != "auto":
data["quality"] = opts["quality"]
if opts.get("background") and opts["background"] != "auto":
data["background"] = opts["background"]
if opts.get("input_fidelity"):
data["input_fidelity"] = opts["input_fidelity"]
response = requests.post(
f"{base_url}/v1/images/edits",
headers=headers,
@@ -86,15 +111,21 @@ class OpenAIProvider(ExternalProvider):
)
if not response.ok:
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("retry-after"))
raise ExternalProviderRateLimitError(
f"OpenAI rate limit exceeded. {f'Retry after {retry_after:.0f}s.' if retry_after else 'Please try again later.'}",
retry_after=retry_after,
)
raise ExternalProviderRequestError(
f"OpenAI request failed with status {response.status_code}: {response.text}"
)
payload = response.json()
if not isinstance(payload, dict):
response_payload = response.json()
if not isinstance(response_payload, dict):
raise ExternalProviderRequestError("OpenAI response payload was not a JSON object")
images: list[ExternalGeneratedImage] = []
data_items = payload.get("data")
data_items = response_payload.get("data")
if not isinstance(data_items, list):
raise ExternalProviderRequestError("OpenAI response payload missing image data")
for item in data_items:
@@ -112,5 +143,14 @@ class OpenAIProvider(ExternalProvider):
images=images,
seed_used=request.seed,
provider_request_id=response.headers.get("x-request-id"),
provider_metadata={"model": request.model.provider_model_id},
provider_metadata={"model": model_id},
)
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None

View File

@@ -9,7 +9,7 @@ from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat,
ExternalGenerationMode = Literal["txt2img", "img2img", "inpaint"]
ExternalMaskFormat = Literal["alpha", "binary", "none"]
ExternalPanelControlName = Literal["negative_prompt", "reference_images", "dimensions", "seed", "steps", "guidance"]
ExternalPanelControlName = Literal["reference_images", "dimensions", "seed"]
class ExternalImageSize(BaseModel):
@@ -51,8 +51,6 @@ class ExternalModelCapabilities(BaseModel):
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")

View File

@@ -939,9 +939,9 @@ gemini_flash_image = StarterModel(
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=[
@@ -981,9 +981,9 @@ gemini_pro_image_preview = StarterModel(
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_negative_prompt=True,
supports_seed=True,
supports_guidance=True,
supports_reference_images=True,
max_reference_images=14,
max_images_per_request=1,
@@ -1003,9 +1003,9 @@ gemini_3_1_flash_image_preview = StarterModel(
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_negative_prompt=True,
supports_seed=True,
supports_guidance=True,
supports_reference_images=True,
max_reference_images=14,
max_images_per_request=1,
@@ -1016,23 +1016,104 @@ gemini_3_1_flash_image_preview = StarterModel(
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
)
openai_gpt_image_1 = StarterModel(
name="ChatGPT Image",
OPENAI_GPT_IMAGE_ASPECT_RATIOS = ["1:1", "3:2", "2:3"]
OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = {
"1:1": ExternalImageSize(width=1024, height=1024),
"3:2": ExternalImageSize(width=1536, height=1024),
"2:3": ExternalImageSize(width=1024, height=1536),
}
OPENAI_GPT_IMAGE_PANEL_SCHEMA = ExternalModelPanelSchema(
prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]
)
openai_gpt_image_1_5 = StarterModel(
name="GPT Image 1.5",
base=BaseModelType.External,
source="external://openai/gpt-image-1",
description="OpenAI GPT-Image-1 image generation model (external API). Requires a configured OpenAI API key and may incur provider usage costs.",
source="external://openai/gpt-image-1.5",
description="OpenAI GPT-Image-1.5 image generation model. Fastest and most affordable GPT image model. Requires a configured OpenAI API key and may incur provider usage costs.",
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,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_gpt_image_1 = StarterModel(
name="GPT Image 1",
base=BaseModelType.External,
source="external://openai/gpt-image-1",
description="OpenAI GPT-Image-1 image generation model. High quality image generation. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_reference_images=True,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_gpt_image_1_mini = StarterModel(
name="GPT Image 1 Mini",
base=BaseModelType.External,
source="external://openai/gpt-image-1-mini",
description="OpenAI GPT-Image-1-Mini image generation model. Cost-efficient option, 80%% cheaper than GPT-Image-1. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_reference_images=True,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_dall_e_3 = StarterModel(
name="DALL-E 3",
base=BaseModelType.External,
source="external://openai/dall-e-3",
description="OpenAI DALL-E 3 image generation model. Supports vivid and natural styles. Only text-to-image, no editing. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
max_images_per_request=1,
allowed_aspect_ratios=["1:1", "7:4", "4:7"],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
"7:4": ExternalImageSize(width=1792, height=1024),
"4:7": ExternalImageSize(width=1024, height=1792),
},
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]),
)
openai_dall_e_2 = StarterModel(
name="DALL-E 2",
base=BaseModelType.External,
source="external://openai/dall-e-2",
description="OpenAI DALL-E 2 image generation model. Supports square images only. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
max_images_per_request=10,
allowed_aspect_ratios=["1:1"],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
},
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]),
)
# endregion
@@ -1134,7 +1215,11 @@ STARTER_MODELS: list[StarterModel] = [
gemini_flash_image,
gemini_pro_image_preview,
gemini_3_1_flash_image_preview,
openai_gpt_image_1_5,
openai_gpt_image_1,
openai_gpt_image_1_mini,
openai_dall_e_3,
openai_dall_e_2,
]
sd1_bundle: list[StarterModel] = [

View File

@@ -30,7 +30,7 @@ const createExternalConfig = (
panelSchema?: ExternalModelPanelSchema
): ExternalApiModelConfig => {
const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 };
const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024, steps: 30 };
const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024 };
return {
key: 'external-test',
@@ -57,21 +57,19 @@ const createExternalConfig = (
};
describe('paramsSlice selectors for external models', () => {
it('uses external capabilities for negative prompt support', () => {
it('returns false for negative prompt support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsNegativePrompt.resultFunc(model, config)).toBe(true);
expect(selectModelSupportsNegativePrompt.resultFunc(model)).toBe(false);
});
it('uses external capabilities for ref image support', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_negative_prompt: false,
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
@@ -79,22 +77,19 @@ describe('paramsSlice selectors for external models', () => {
expect(selectModelSupportsRefImages.resultFunc(model, config)).toBe(false);
});
it('uses external capabilities for guidance support', () => {
it('returns false for guidance support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: false,
supports_guidance: true,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsGuidance.resultFunc(model, config)).toBe(true);
expect(selectModelSupportsGuidance.resultFunc(model)).toBe(false);
});
it('uses external capabilities for seed support', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: false,
supports_seed: false,
});
@@ -103,27 +98,22 @@ describe('paramsSlice selectors for external models', () => {
expect(selectModelSupportsSeed.resultFunc(model, config)).toBe(false);
});
it('uses external capabilities for steps support', () => {
it('returns false for steps support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: false,
supports_steps: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsSteps.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsSteps.resultFunc(model)).toBe(false);
});
it('prefers panel schema over capabilities for control visibility', () => {
const config = createExternalConfig(
{
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: true,
supports_guidance: true,
supports_seed: true,
supports_steps: true,
},
{
prompts: [{ name: 'reference_images' }],
@@ -133,11 +123,11 @@ describe('paramsSlice selectors for external models', () => {
);
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsNegativePrompt.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsNegativePrompt.resultFunc(model)).toBe(false);
expect(selectModelSupportsRefImages.resultFunc(model, config)).toBe(true);
expect(selectModelSupportsGuidance.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsGuidance.resultFunc(model)).toBe(false);
expect(selectModelSupportsSeed.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsSteps.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsSteps.resultFunc(model)).toBe(false);
expect(selectModelSupportsDimensions.resultFunc(model, config)).toBe(true);
});
});

View File

@@ -448,6 +448,21 @@ const slice = createSlice({
imageSizeChanged: (state, action: PayloadAction<string | null>) => {
state.imageSize = action.payload;
},
openaiQualityChanged: (state, action: PayloadAction<'auto' | 'high' | 'medium' | 'low'>) => {
state.openaiQuality = action.payload;
},
openaiBackgroundChanged: (state, action: PayloadAction<'auto' | 'transparent' | 'opaque'>) => {
state.openaiBackground = action.payload;
},
openaiInputFidelityChanged: (state, action: PayloadAction<'low' | 'high' | null>) => {
state.openaiInputFidelity = action.payload;
},
geminiTemperatureChanged: (state, action: PayloadAction<number | null>) => {
state.geminiTemperature = action.payload;
},
geminiThinkingLevelChanged: (state, action: PayloadAction<'minimal' | 'high' | null>) => {
state.geminiThinkingLevel = action.payload;
},
resolutionPresetSelected: (
state,
action: PayloadAction<{ imageSize: string; aspectRatio: string; width: number; height: number }>
@@ -593,6 +608,11 @@ export const {
resolutionPresetSelected,
paramsReset,
openaiQualityChanged,
openaiBackgroundChanged,
openaiInputFidelityChanged,
geminiTemperatureChanged,
geminiThinkingLevelChanged,
} = slice.actions;
export const paramsSliceConfig: SliceConfig<typeof slice> = {
@@ -694,22 +714,15 @@ export const selectModelConfig = createSelector(
}
);
export const selectHasNegativePrompt = createParamsSelector((params) => params.negativePrompt !== null);
export const selectModelSupportsNegativePrompt = createSelector(
selectModel,
selectModelConfig,
(model, modelConfig) => {
if (!model) {
return false;
}
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return hasExternalPanelControl(modelConfig, 'prompts', 'negative_prompt');
}
if (model.base === 'external') {
return false;
}
return SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS.includes(model.base);
export const selectModelSupportsNegativePrompt = createSelector(selectModel, (model) => {
if (!model) {
return false;
}
);
if (model.base === 'external') {
return false;
}
return SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS.includes(model.base);
});
export const selectModelSupportsRefImages = createSelector(selectModel, selectModelConfig, (model, modelConfig) => {
if (!model) {
return false;
@@ -726,12 +739,12 @@ export const selectModelSupportsOptimizedDenoising = createSelector(
selectModel,
(model) => !!model && model.base !== 'external' && SUPPORTS_OPTIMIZED_DENOISING_BASE_MODELS.includes(model.base)
);
export const selectModelSupportsGuidance = createSelector(selectModel, selectModelConfig, (model, modelConfig) => {
export const selectModelSupportsGuidance = createSelector(selectModel, (model) => {
if (!model) {
return false;
}
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return hasExternalPanelControl(modelConfig, 'generation', 'guidance');
if (model.base === 'external') {
return false;
}
return true;
});
@@ -744,12 +757,12 @@ export const selectModelSupportsSeed = createSelector(selectModel, selectModelCo
}
return true;
});
export const selectModelSupportsSteps = createSelector(selectModel, selectModelConfig, (model, modelConfig) => {
export const selectModelSupportsSteps = createSelector(selectModel, (model) => {
if (!model) {
return false;
}
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return hasExternalPanelControl(modelConfig, 'generation', 'steps');
if (model.base === 'external') {
return false;
}
return true;
});
@@ -762,18 +775,6 @@ export const selectModelSupportsDimensions = createSelector(selectModel, selectM
}
return true;
});
export const selectStepsControl = createSelector(selectModelConfig, (modelConfig) => {
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return getExternalPanelControl(modelConfig, 'generation', 'steps');
}
return null;
});
export const selectGuidanceControl = createSelector(selectModelConfig, (modelConfig) => {
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return getExternalPanelControl(modelConfig, 'generation', 'guidance');
}
return null;
});
export const selectSeedControl = createSelector(selectModelConfig, (modelConfig) => {
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return getExternalPanelControl(modelConfig, 'image', 'seed');
@@ -847,6 +848,17 @@ export const selectHasFixedDimensionSizes = createSelector(
(sizes, presets) => sizes !== null || (presets !== null && presets.length > 0)
);
export const selectImageSize = createParamsSelector((params) => params.imageSize);
export const selectOpenaiQuality = createParamsSelector((params) => params.openaiQuality);
export const selectOpenaiBackground = createParamsSelector((params) => params.openaiBackground);
export const selectOpenaiInputFidelity = createParamsSelector((params) => params.openaiInputFidelity);
export const selectGeminiTemperature = createParamsSelector((params) => params.geminiTemperature);
export const selectGeminiThinkingLevel = createParamsSelector((params) => params.geminiThinkingLevel);
export const selectExternalProviderId = createSelector(selectModelConfig, (modelConfig) => {
if (modelConfig && isExternalApiModelConfig(modelConfig)) {
return modelConfig.provider_id;
}
return null;
});
export const selectMainModelConfig = createSelector(selectModelConfig, (modelConfig) => {
if (!modelConfig) {

View File

@@ -758,6 +758,13 @@ export const zParamsState = z.object({
zImageSeedVarianceStrength: z.number().min(0).max(2),
zImageSeedVarianceRandomizePercent: z.number().min(1).max(100),
imageSize: z.string().nullable().default(null),
// OpenAI-specific external options
openaiQuality: z.enum(['auto', 'high', 'medium', 'low']).default('auto'),
openaiBackground: z.enum(['auto', 'transparent', 'opaque']).default('auto'),
openaiInputFidelity: z.enum(['low', 'high']).nullable().default(null),
// Gemini-specific external options
geminiTemperature: z.number().min(0).max(2).nullable().default(null),
geminiThinkingLevel: z.enum(['minimal', 'high']).nullable().default(null),
dimensions: zDimensionsState,
});
export type ParamsState = z.infer<typeof zParamsState>;
@@ -822,6 +829,11 @@ export const getInitialParamsState = (): ParamsState => ({
zImageSeedVarianceStrength: 0.1,
zImageSeedVarianceRandomizePercent: 50,
imageSize: null,
openaiQuality: 'auto',
openaiBackground: 'auto',
openaiInputFidelity: null,
geminiTemperature: null,
geminiThinkingLevel: null,
dimensions: {
width: 512,
height: 512,

View File

@@ -224,6 +224,7 @@ const ProviderCard = memo(({ provider, onInstallModels }: ProviderCardProps) =>
<FormLabel>{t('modelManager.externalApiKey')}</FormLabel>
<Input
type="password"
autoComplete="off"
placeholder={
provider.api_key_configured
? t('modelManager.externalApiKeyPlaceholderSet')

View File

@@ -241,10 +241,6 @@ export const ModelEdit = memo(({ modelConfig }: Props) => {
</Checkbox>
</Flex>
</FormControl>
<FormControl flexDir="column" alignItems="flex-start" gap={1}>
<FormLabel>{t('modelManager.supportsNegativePrompt')}</FormLabel>
<Checkbox {...form.register('capabilities.supports_negative_prompt')} />
</FormControl>
<FormControl flexDir="column" alignItems="flex-start" gap={1}>
<FormLabel>{t('modelManager.supportsReferenceImages')}</FormLabel>
<Checkbox {...form.register('capabilities.supports_reference_images')} />
@@ -253,10 +249,6 @@ export const ModelEdit = memo(({ modelConfig }: Props) => {
<FormLabel>{t('modelManager.supportsSeed')}</FormLabel>
<Checkbox {...form.register('capabilities.supports_seed')} />
</FormControl>
<FormControl flexDir="column" alignItems="flex-start" gap={1}>
<FormLabel>{t('modelManager.supportsGuidance')}</FormLabel>
<Checkbox {...form.register('capabilities.supports_guidance')} />
</FormControl>
<FormControl flexDir="column" alignItems="flex-start" gap={1}>
<FormLabel>{t('modelManager.maxImagesPerRequest')}</FormLabel>
<Input

View File

@@ -15,14 +15,11 @@ import { buildExternalGraph } from './buildExternalGraph';
const createExternalModel = (overrides: Partial<ExternalApiModelConfig> = {}): ExternalApiModelConfig => {
const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 };
const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024, steps: 30 };
const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024 };
const capabilities: ExternalModelCapabilities = {
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: true,
supports_seed: true,
supports_guidance: true,
supports_steps: true,
max_image_size: maxImageSize,
};
@@ -54,7 +51,6 @@ const createExternalModel = (overrides: Partial<ExternalApiModelConfig> = {}): E
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 = {
@@ -80,7 +76,6 @@ vi.mock('features/nodes/util/graph/graphBuilderUtils', () => ({
rect: { x: 0, y: 0, width: 512, height: 512 },
}),
selectCanvasOutputFields: () => mockOutputFields,
selectPresetModifiedPrompts: () => mockPrompts,
}));
beforeEach(() => {
@@ -88,7 +83,6 @@ beforeEach(() => {
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;
@@ -129,44 +123,14 @@ describe('buildExternalGraph', () => {
expect(externalNode?.mode).toBe('txt2img');
expect(externalNode?.width).toBe(768);
expect(externalNode?.height).toBe(512);
expect(externalNode?.negative_prompt).toBe('bad prompt');
expect(externalNode?.steps).toBe(20);
expect(externalNode?.guidance).toBe(4.5);
expect((externalNode?.reference_images as Array<{ image_name: string }> | undefined)?.[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('does not include steps when model does not support them', async () => {
const modelConfig = createExternalModel({
capabilities: {
modes: ['txt2img'],
supports_negative_prompt: true,
supports_reference_images: true,
supports_seed: true,
supports_guidance: true,
supports_steps: false,
},
});
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 === 'openai_image_generation') as
| Record<string, unknown>
| undefined;
expect(externalNode?.steps).toBeNull();
});
it('prefers panel schema over capabilities when building node inputs', async () => {
const panelSchema: ExternalModelPanelSchema = {
prompts: [{ name: 'reference_images' }],
@@ -187,9 +151,6 @@ describe('buildExternalGraph', () => {
| Record<string, unknown>
| undefined;
expect(externalNode?.negative_prompt).toBeNull();
expect(externalNode?.steps).toBeNull();
expect(externalNode?.guidance).toBeNull();
expect((externalNode?.reference_images as Array<{ image_name: string }> | undefined)?.[0]).toEqual({
image_name: 'ref.png',
});

View File

@@ -8,7 +8,6 @@ import {
getOriginalAndScaledSizesForOtherModes,
getOriginalAndScaledSizesForTextToImage,
selectCanvasOutputFields,
selectPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import {
type GraphBuilderArg,
@@ -44,13 +43,9 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise<GraphBui
const params = selectParamsSlice(state);
const refImages = selectRefImagesSlice(state);
const prompts = selectPresetModifiedPrompts(state);
const g = new Graph(getPrefixedId('external_graph'));
const supportsSeed = hasExternalPanelControl(model, 'image', 'seed');
const supportsNegativePrompt = hasExternalPanelControl(model, 'prompts', 'negative_prompt');
const supportsSteps = hasExternalPanelControl(model, 'generation', 'steps');
const supportsGuidance = hasExternalPanelControl(model, 'generation', 'guidance');
const supportsReferenceImages = hasExternalPanelControl(model, 'prompts', 'reference_images');
const seed = supportsSeed
@@ -71,12 +66,25 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise<GraphBui
type: externalNodeType ?? 'external_image_generation',
model: model as unknown as ModelIdentifierField,
mode: requestedMode,
negative_prompt: supportsNegativePrompt ? prompts.negative : null,
steps: supportsSteps ? params.steps : null,
guidance: supportsGuidance ? params.guidance : null,
image_size: params.imageSize ?? null,
num_images: 1,
};
// Provider-specific options
if (model.provider_id === 'openai') {
externalNode.quality = params.openaiQuality;
externalNode.background = params.openaiBackground;
if (params.openaiInputFidelity) {
externalNode.input_fidelity = params.openaiInputFidelity;
}
} else if (model.provider_id === 'gemini') {
if (params.geminiTemperature !== null) {
externalNode.temperature = params.geminiTemperature;
}
if (params.geminiThinkingLevel) {
externalNode.thinking_level = params.geminiThinkingLevel;
}
}
g.addNode(externalNode as AnyInvocation);
if (seed) {
@@ -97,17 +105,8 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise<GraphBui
.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;
}
}
}

View File

@@ -22,9 +22,7 @@ const createExternalModel = (overrides: Partial<ExternalApiModelConfig> = {}): E
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'],

View File

@@ -1,8 +1,8 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { selectGuidance, selectGuidanceControl, setGuidance } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback, useMemo } from 'react';
import { selectGuidance, setGuidance } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const CONSTRAINTS = {
@@ -23,22 +23,10 @@ export const MARKS = [
const ParamGuidance = () => {
const guidance = useAppSelector(selectGuidance);
const externalControl = useAppSelector(selectGuidanceControl);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback((v: number) => dispatch(setGuidance(v)), [dispatch]);
const sliderMin = externalControl?.slider_min ?? CONSTRAINTS.sliderMin;
const sliderMax = externalControl?.slider_max ?? CONSTRAINTS.sliderMax;
const numberInputMin = externalControl?.number_input_min ?? CONSTRAINTS.numberInputMin;
const numberInputMax = externalControl?.number_input_max ?? CONSTRAINTS.numberInputMax;
const fineStep = externalControl?.fine_step ?? CONSTRAINTS.fineStep;
const coarseStep = externalControl?.coarse_step ?? CONSTRAINTS.coarseStep;
const marks = useMemo(
() => externalControl?.marks ?? [sliderMin, Math.floor(sliderMax - (sliderMax - sliderMin) / 2), sliderMax],
[externalControl?.marks, sliderMin, sliderMax]
);
return (
<FormControl>
<InformationalPopover feature="paramGuidance">
@@ -47,20 +35,20 @@ const ParamGuidance = () => {
<CompositeSlider
value={guidance}
defaultValue={CONSTRAINTS.initial}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
min={CONSTRAINTS.sliderMin}
max={CONSTRAINTS.sliderMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
onChange={onChange}
marks={marks}
marks={MARKS}
/>
<CompositeNumberInput
value={guidance}
defaultValue={CONSTRAINTS.initial}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
min={CONSTRAINTS.numberInputMin}
max={CONSTRAINTS.numberInputMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
onChange={onChange}
/>
</FormControl>

View File

@@ -1,8 +1,8 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { selectSteps, selectStepsControl, setSteps } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback, useMemo } from 'react';
import { selectSteps, setSteps } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const CONSTRAINTS = {
@@ -19,7 +19,6 @@ export const MARKS = [CONSTRAINTS.sliderMin, Math.floor(CONSTRAINTS.sliderMax /
const ParamSteps = () => {
const steps = useAppSelector(selectSteps);
const externalControl = useAppSelector(selectStepsControl);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback(
@@ -29,17 +28,6 @@ const ParamSteps = () => {
[dispatch]
);
const sliderMin = externalControl?.slider_min ?? CONSTRAINTS.sliderMin;
const sliderMax = externalControl?.slider_max ?? CONSTRAINTS.sliderMax;
const numberInputMin = externalControl?.number_input_min ?? CONSTRAINTS.numberInputMin;
const numberInputMax = externalControl?.number_input_max ?? CONSTRAINTS.numberInputMax;
const fineStep = externalControl?.fine_step ?? CONSTRAINTS.fineStep;
const coarseStep = externalControl?.coarse_step ?? CONSTRAINTS.coarseStep;
const marks = useMemo(
() => externalControl?.marks ?? [sliderMin, Math.floor(sliderMax / 2), sliderMax],
[externalControl?.marks, sliderMin, sliderMax]
);
return (
<FormControl>
<InformationalPopover feature="paramSteps">
@@ -48,20 +36,20 @@ const ParamSteps = () => {
<CompositeSlider
value={steps}
defaultValue={CONSTRAINTS.initial}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
min={CONSTRAINTS.sliderMin}
max={CONSTRAINTS.sliderMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
onChange={onChange}
marks={marks}
marks={MARKS}
/>
<CompositeNumberInput
value={steps}
defaultValue={CONSTRAINTS.initial}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
min={CONSTRAINTS.numberInputMin}
max={CONSTRAINTS.numberInputMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
onChange={onChange}
/>
</FormControl>

View File

@@ -22,9 +22,7 @@ const createExternalModel = (overrides: Partial<ExternalApiModelConfig> = {}): E
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'],

View File

@@ -0,0 +1,74 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
geminiTemperatureChanged,
geminiThinkingLevelChanged,
selectGeminiTemperature,
selectGeminiThinkingLevel,
} from 'features/controlLayers/store/paramsSlice';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
const TEMPERATURE_MARKS = [0, 1, 2];
export const GeminiProviderOptions = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const temperature = useAppSelector(selectGeminiTemperature);
const thinkingLevel = useAppSelector(selectGeminiThinkingLevel);
const onTemperatureChange = useCallback((v: number) => dispatch(geminiTemperatureChanged(v)), [dispatch]);
const onThinkingLevelChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
const value = e.target.value;
dispatch(geminiThinkingLevelChanged(value === '' ? null : (value as 'minimal' | 'high')));
},
[dispatch]
);
return (
<>
<FormControl>
<FormLabel>{t('parameters.temperature', 'Temperature')}</FormLabel>
<CompositeSlider
value={temperature ?? 1}
defaultValue={1}
min={0}
max={2}
step={0.1}
fineStep={0.05}
onChange={onTemperatureChange}
marks={TEMPERATURE_MARKS}
/>
<CompositeNumberInput
value={temperature ?? 1}
defaultValue={1}
min={0}
max={2}
step={0.1}
fineStep={0.05}
onChange={onTemperatureChange}
/>
</FormControl>
<FormControl>
<FormLabel>{t('parameters.thinkingLevel', 'Thinking Level')}</FormLabel>
<Select
size="sm"
value={thinkingLevel ?? ''}
onChange={onThinkingLevelChange}
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="">Default</option>
<option value="minimal">Minimal</option>
<option value="high">High</option>
</Select>
</FormControl>
</>
);
});
GeminiProviderOptions.displayName = 'GeminiProviderOptions';

View File

@@ -0,0 +1,84 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
openaiBackgroundChanged,
openaiInputFidelityChanged,
openaiQualityChanged,
selectOpenaiBackground,
selectOpenaiInputFidelity,
selectOpenaiQuality,
} from 'features/controlLayers/store/paramsSlice';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
export const OpenAIProviderOptions = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const quality = useAppSelector(selectOpenaiQuality);
const background = useAppSelector(selectOpenaiBackground);
const inputFidelity = useAppSelector(selectOpenaiInputFidelity);
const onQualityChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => dispatch(openaiQualityChanged(e.target.value as 'auto' | 'high' | 'medium' | 'low')),
[dispatch]
);
const onBackgroundChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => dispatch(openaiBackgroundChanged(e.target.value as 'auto' | 'transparent' | 'opaque')),
[dispatch]
);
const onInputFidelityChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
const value = e.target.value;
dispatch(openaiInputFidelityChanged(value === '' ? null : (value as 'low' | 'high')));
},
[dispatch]
);
return (
<>
<FormControl>
<FormLabel>{t('parameters.quality', 'Quality')}</FormLabel>
<Select size="sm" value={quality} onChange={onQualityChange} icon={<PiCaretDownBold />} iconSize="0.75rem">
<option value="auto">Auto</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>{t('parameters.background', 'Background')}</FormLabel>
<Select
size="sm"
value={background}
onChange={onBackgroundChange}
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="auto">Auto</option>
<option value="transparent">Transparent</option>
<option value="opaque">Opaque</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>{t('parameters.inputFidelity', 'Input Fidelity')}</FormLabel>
<Select
size="sm"
value={inputFidelity ?? ''}
onChange={onInputFidelityChange}
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="">Default</option>
<option value="low">Low</option>
<option value="high">High</option>
</Select>
</FormControl>
</>
);
});
OpenAIProviderOptions.displayName = 'OpenAIProviderOptions';

View File

@@ -30,7 +30,6 @@ const createExternalConfig = (modes: ExternalModelCapabilities['modes']): Extern
provider_model_id: 'gpt-image-1',
capabilities: {
modes,
supports_negative_prompt: true,
supports_reference_images: false,
max_image_size: maxImageSize,
},

View File

@@ -11,15 +11,9 @@ type ExternalPanelName = keyof ExternalModelPanelSchema;
const buildExternalPanelSchemaFromCapabilities = (
capabilities: ExternalModelCapabilities
): ExternalModelPanelSchema => ({
prompts: [
...(capabilities.supports_negative_prompt ? [{ name: 'negative_prompt' as const }] : []),
...(capabilities.supports_reference_images ? [{ name: 'reference_images' as const }] : []),
],
prompts: [...(capabilities.supports_reference_images ? [{ name: 'reference_images' as const }] : [])],
image: [{ name: 'dimensions' }, ...(capabilities.supports_seed ? [{ name: 'seed' as const }] : [])],
generation: [
...(capabilities.supports_steps ? [{ name: 'steps' as const }] : []),
...(capabilities.supports_guidance ? [{ name: 'guidance' as const }] : []),
],
generation: [],
});
const getExternalPanelSchema = (modelConfig: ExternalApiModelConfig): ExternalModelPanelSchema =>

View File

@@ -1,9 +1,11 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup, StandaloneAccordion } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsExternal } from 'features/controlLayers/store/paramsSlice';
import { selectExternalProviderId, selectIsExternal } from 'features/controlLayers/store/paramsSlice';
import { ExternalModelImageSizeSelect } from 'features/parameters/components/Dimensions/ExternalModelImageSizeSelect';
import { ExternalModelResolutionSelect } from 'features/parameters/components/Dimensions/ExternalModelResolutionSelect';
import { GeminiProviderOptions } from 'features/parameters/components/External/GeminiProviderOptions';
import { OpenAIProviderOptions } from 'features/parameters/components/External/OpenAIProviderOptions';
import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,6 +17,7 @@ const formLabelProps: FormLabelProps = {
export const ExternalSettingsAccordion = memo(() => {
const { t } = useTranslation();
const isExternal = useAppSelector(selectIsExternal);
const providerId = useAppSelector(selectExternalProviderId);
const { isOpen, onToggle } = useStandaloneAccordionToggle({
id: 'external-settings',
defaultIsOpen: true,
@@ -35,6 +38,8 @@ export const ExternalSettingsAccordion = memo(() => {
<FormControlGroup formLabelProps={formLabelProps}>
<ExternalModelResolutionSelect />
<ExternalModelImageSizeSelect />
{providerId === 'openai' && <OpenAIProviderOptions />}
{providerId === 'gemini' && <GeminiProviderOptions />}
</FormControlGroup>
</Flex>
</StandaloneAccordion>

View File

@@ -27,7 +27,6 @@ def _build_model() -> ExternalApiModelConfig:
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_reference_images=True,
supports_negative_prompt=True,
supports_seed=True,
),
)
@@ -57,47 +56,22 @@ def test_external_invocation_builds_request_and_outputs() -> None:
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_provider_specific_external_invocation_rejects_wrong_provider() -> None:
model_config = _build_model().model_copy(update={"provider_id": "gemini"})
model_field = ModelIdentifierField.from_config(model_config)

View File

@@ -83,9 +83,9 @@ def test_model_manager_external_config_preserves_custom_panel_schema(
name="External Custom Schema",
provider_id="custom",
provider_model_id="custom-model",
capabilities=ExternalModelCapabilities(modes=["txt2img"], supports_negative_prompt=True),
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
panel_schema=ExternalModelPanelSchema(
prompts=[{"name": "negative_prompt"}],
prompts=[{"name": "reference_images"}],
image=[{"name": "dimensions"}],
),
source="external://custom/custom-model",
@@ -104,7 +104,7 @@ def test_model_manager_external_config_preserves_custom_panel_schema(
assert response.status_code == 200
payload = response.json()
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["negative_prompt"]
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["reference_images"]
assert [control["name"] for control in payload["panel_schema"]["image"]] == ["dimensions"]
@@ -118,10 +118,7 @@ def test_model_manager_external_starter_model_applies_panel_schema_overrides(
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_negative_prompt=True,
supports_reference_images=False,
supports_guidance=True,
supports_steps=True,
),
)
mm2_model_manager.store.add_model(config)

View File

@@ -55,10 +55,8 @@ 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,
@@ -69,14 +67,11 @@ def _build_request(
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,
image_size=None,
steps=10,
guidance=guidance,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images or [],
@@ -117,16 +112,6 @@ def test_generate_validates_mode_support() -> None:
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")
@@ -151,7 +136,7 @@ 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)],
reference_images=[ExternalReferenceImage(image=_make_image())],
)
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
@@ -231,9 +216,9 @@ def test_generate_validates_allowed_aspect_ratios_with_bucket_sizes() -> None:
def test_generate_happy_path() -> None:
model = _build_model(
ExternalModelCapabilities(modes=["txt2img"], supports_negative_prompt=True, supports_seed=True)
ExternalModelCapabilities(modes=["txt2img"], supports_seed=True)
)
request = _build_request(model=model, negative_prompt="", seed=42)
request = _build_request(model=model, 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"))

View File

@@ -40,10 +40,8 @@ def _build_model(provider_id: str, provider_model_id: str) -> ExternalApiModelCo
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,
),
)
@@ -59,14 +57,11 @@ def _build_request(
model=model,
mode=mode, # type: ignore[arg-type]
prompt="A test prompt",
negative_prompt="",
seed=123,
num_images=1,
width=256,
height=256,
image_size=None,
steps=20,
guidance=5.5,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images or [],
@@ -84,7 +79,7 @@ def test_gemini_generate_success(monkeypatch: pytest.MonkeyPatch) -> None:
request = _build_request(
model,
init_image=init_image,
reference_images=[ExternalReferenceImage(image=ref_image, weight=0.6)],
reference_images=[ExternalReferenceImage(image=ref_image)],
)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}