mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
74
invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx
vendored
Normal file
74
invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx
vendored
Normal 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';
|
||||
84
invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx
vendored
Normal file
84
invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx
vendored
Normal 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';
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user