From d8d0ebc356599bc5eb67925e08e83bb3e38b6a0c Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 20 Mar 2026 08:17:16 +0100 Subject: [PATCH] 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 --- .../invocations/external_image_generation.py | 61 ++++++---- .../services/external_generation/errors.py | 10 ++ .../external_generation_common.py | 6 +- .../external_generation_default.py | 42 +++++-- .../external_generation/providers/gemini.py | 23 +++- .../external_generation/providers/openai.py | 58 +++++++-- .../model_manager/configs/external_api.py | 4 +- .../backend/model_manager/starter_models.py | 115 +++++++++++++++--- .../controlLayers/store/paramsSlice.test.ts | 30 ++--- .../controlLayers/store/paramsSlice.ts | 78 +++++++----- .../src/features/controlLayers/store/types.ts | 12 ++ .../ExternalProvidersForm.tsx | 1 + .../subpanels/ModelPanel/ModelEdit.tsx | 8 -- .../generation/buildExternalGraph.test.ts | 41 +------ .../graph/generation/buildExternalGraph.ts | 33 +++-- .../Bbox/BboxAspectRatioSelect.test.tsx | 2 - .../components/Core/ParamGuidance.tsx | 34 ++---- .../parameters/components/Core/ParamSteps.tsx | 34 ++---- .../DimensionsAspectRatioSelect.test.tsx | 2 - .../External/GeminiProviderOptions.tsx | 74 +++++++++++ .../External/OpenAIProviderOptions.tsx | 84 +++++++++++++ .../MainModel/mainModelPickerUtils.test.ts | 1 - .../parameters/util/externalPanelSchema.ts | 10 +- .../ExternalSettingsAccordion.tsx | 7 +- .../test_external_image_generation.py | 26 ---- tests/app/routers/test_model_manager.py | 9 +- .../test_external_generation_service.py | 21 +--- .../test_external_provider_adapters.py | 7 +- 28 files changed, 530 insertions(+), 303 deletions(-) create mode 100644 invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx create mode 100644 invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx diff --git a/invokeai/app/invocations/external_image_generation.py b/invokeai/app/invocations/external_image_generation.py index c66b024bfa..b66affe9b0 100644 --- a/invokeai/app/invocations/external_image_generation.py +++ b/invokeai/app/invocations/external_image_generation.py @@ -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 diff --git a/invokeai/app/services/external_generation/errors.py b/invokeai/app/services/external_generation/errors.py index 9980b39bc4..f61a6a8c73 100644 --- a/invokeai/app/services/external_generation/errors.py +++ b/invokeai/app/services/external_generation/errors.py @@ -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 diff --git a/invokeai/app/services/external_generation/external_generation_common.py b/invokeai/app/services/external_generation/external_generation_common.py index a6746913c1..f14bff52dd 100644 --- a/invokeai/app/services/external_generation/external_generation_common.py +++ b/invokeai/app/services/external_generation/external_generation_common.py @@ -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) diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py index 9265e63b7f..2622aa9b1c 100644 --- a/invokeai/app/services/external_generation/external_generation_default.py +++ b/invokeai/app/services/external_generation/external_generation_default.py @@ -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, ) diff --git a/invokeai/app/services/external_generation/providers/gemini.py b/invokeai/app/services/external_generation/providers/gemini.py index 855d64d945..7f289cced7 100644 --- a/invokeai/app/services/external_generation/providers/gemini.py +++ b/invokeai/app/services/external_generation/providers/gemini.py @@ -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 diff --git a/invokeai/app/services/external_generation/providers/openai.py b/invokeai/app/services/external_generation/providers/openai.py index f06491a225..5051cf9cf1 100644 --- a/invokeai/app/services/external_generation/providers/openai.py +++ b/invokeai/app/services/external_generation/providers/openai.py @@ -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 diff --git a/invokeai/backend/model_manager/configs/external_api.py b/invokeai/backend/model_manager/configs/external_api.py index 4d105a65f6..da58cba410 100644 --- a/invokeai/backend/model_manager/configs/external_api.py +++ b/invokeai/backend/model_manager/configs/external_api.py @@ -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") diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py index 5e9d422681..f047dc48e8 100644 --- a/invokeai/backend/model_manager/starter_models.py +++ b/invokeai/backend/model_manager/starter_models.py @@ -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] = [ diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts index afdba0212b..7d665a3818 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts @@ -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); }); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 87b3789a32..bd41df17f0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -448,6 +448,21 @@ const slice = createSlice({ imageSizeChanged: (state, action: PayloadAction) => { 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) => { + 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 = { @@ -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) { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 8c62f73b8e..77ad6619db 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -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; @@ -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, 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 index 59fb868a50..aa54071868 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx @@ -224,6 +224,7 @@ const ProviderCard = memo(({ provider, onInstallModels }: ProviderCardProps) => {t('modelManager.externalApiKey')} { - - {t('modelManager.supportsNegativePrompt')} - - {t('modelManager.supportsReferenceImages')} @@ -253,10 +249,6 @@ export const ModelEdit = memo(({ modelConfig }: Props) => { {t('modelManager.supportsSeed')} - - {t('modelManager.supportsGuidance')} - - {t('modelManager.maxImagesPerRequest')} = {}): 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 = {}): 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 - | 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 | 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', }); 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 index 4f686a5986..0ba82234a6 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts @@ -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 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; - } } } 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 index 1ae1dcdc3a..8fb4e81b59 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxAspectRatioSelect.test.tsx @@ -22,9 +22,7 @@ const createExternalModel = (overrides: Partial = {}): 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'], diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamGuidance.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamGuidance.tsx index 80e9d188b3..62fc5aa826 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamGuidance.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamGuidance.tsx @@ -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 ( @@ -47,20 +35,20 @@ const ParamGuidance = () => { diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx index b6d810dd2e..31efe5d0a6 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx @@ -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 ( @@ -48,20 +36,20 @@ const ParamSteps = () => { 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 index 636260d1d2..0651c47863 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.test.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.test.tsx @@ -22,9 +22,7 @@ const createExternalModel = (overrides: Partial = {}): 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'], diff --git a/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx b/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx new file mode 100644 index 0000000000..1ec27ef809 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx @@ -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>( + (e) => { + const value = e.target.value; + dispatch(geminiThinkingLevelChanged(value === '' ? null : (value as 'minimal' | 'high'))); + }, + [dispatch] + ); + + return ( + <> + + {t('parameters.temperature', 'Temperature')} + + + + + {t('parameters.thinkingLevel', 'Thinking Level')} + + + + ); +}); + +GeminiProviderOptions.displayName = 'GeminiProviderOptions'; diff --git a/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx b/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx new file mode 100644 index 0000000000..f4eba05d84 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx @@ -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>( + (e) => dispatch(openaiQualityChanged(e.target.value as 'auto' | 'high' | 'medium' | 'low')), + [dispatch] + ); + + const onBackgroundChange = useCallback>( + (e) => dispatch(openaiBackgroundChanged(e.target.value as 'auto' | 'transparent' | 'opaque')), + [dispatch] + ); + + const onInputFidelityChange = useCallback>( + (e) => { + const value = e.target.value; + dispatch(openaiInputFidelityChanged(value === '' ? null : (value as 'low' | 'high'))); + }, + [dispatch] + ); + + return ( + <> + + {t('parameters.quality', 'Quality')} + + + + {t('parameters.background', 'Background')} + + + + {t('parameters.inputFidelity', 'Input Fidelity')} + + + + ); +}); + +OpenAIProviderOptions.displayName = 'OpenAIProviderOptions'; 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 index b908efa096..7d72b529c2 100644 --- a/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.test.ts @@ -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, }, diff --git a/invokeai/frontend/web/src/features/parameters/util/externalPanelSchema.ts b/invokeai/frontend/web/src/features/parameters/util/externalPanelSchema.ts index 9c1296b1a5..c49190b225 100644 --- a/invokeai/frontend/web/src/features/parameters/util/externalPanelSchema.ts +++ b/invokeai/frontend/web/src/features/parameters/util/externalPanelSchema.ts @@ -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 => diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx index deb3ea2c83..2988acbd19 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx @@ -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(() => { + {providerId === 'openai' && } + {providerId === 'gemini' && } diff --git a/tests/app/invocations/test_external_image_generation.py b/tests/app/invocations/test_external_image_generation.py index 34a2d92162..9cd2919d46 100644 --- a/tests/app/invocations/test_external_image_generation.py +++ b/tests/app/invocations/test_external_image_generation.py @@ -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) diff --git a/tests/app/routers/test_model_manager.py b/tests/app/routers/test_model_manager.py index 771790ee9c..3260b539bb 100644 --- a/tests/app/routers/test_model_manager.py +++ b/tests/app/routers/test_model_manager.py @@ -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) diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py index 4ad0899c9c..927f60b6ee 100644 --- a/tests/app/services/external_generation/test_external_generation_service.py +++ b/tests/app/services/external_generation/test_external_generation_service.py @@ -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")) 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 a7493ec056..a64f197b4d 100644 --- a/tests/app/services/external_generation/test_external_provider_adapters.py +++ b/tests/app/services/external_generation/test_external_provider_adapters.py @@ -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] = {}