diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py
index f2cd65aa1c6..832e58f5e24 100644
--- a/invokeai/app/api/routers/app_info.py
+++ b/invokeai/app/api/routers/app_info.py
@@ -201,6 +201,7 @@ async def get_external_provider_configs() -> list[ExternalProviderConfigModel]:
response_model=ExternalProviderConfigModel,
)
async def set_external_provider_config(
+ _: AdminUserOrDefault,
provider_id: str = Path(description="The external provider identifier"),
update: ExternalProviderConfigUpdate = Body(description="External provider configuration settings"),
) -> ExternalProviderConfigModel:
@@ -231,6 +232,7 @@ async def set_external_provider_config(
response_model=ExternalProviderConfigModel,
)
async def reset_external_provider_config(
+ _: AdminUserOrDefault,
provider_id: str = Path(description="The external provider identifier"),
) -> ExternalProviderConfigModel:
api_key_field, base_url_field = _get_external_provider_fields(provider_id)
diff --git a/invokeai/app/services/external_generation/providers/seedream.py b/invokeai/app/services/external_generation/providers/seedream.py
index 3ad445b9490..13b05af7e38 100644
--- a/invokeai/app/services/external_generation/providers/seedream.py
+++ b/invokeai/app/services/external_generation/providers/seedream.py
@@ -3,6 +3,7 @@
import requests
from invokeai.app.services.external_generation.errors import (
+ ExternalProviderCapabilityError,
ExternalProviderRateLimitError,
ExternalProviderRequestError,
)
@@ -23,6 +24,11 @@
"seedream-5-0",
)
+# Seedream batch endpoint accepts up to 15 total images counting both inputs (reference + init)
+# and outputs combined. Hitting this only after the API call wastes a request and produces a
+# confusing 400, so we enforce it locally for batch-capable models.
+_SEEDREAM_BATCH_MAX_TOTAL_IMAGES = 15
+
class SeedreamProvider(ExternalProvider):
provider_id = "seedream"
@@ -44,6 +50,15 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu
model_id = request.model.provider_model_id
is_batch_model = any(model_id.startswith(prefix) for prefix in _SEEDREAM_BATCH_PREFIXES)
+ if is_batch_model:
+ input_image_count = len(request.reference_images) + (1 if request.init_image is not None else 0)
+ total_images = input_image_count + request.num_images
+ if total_images > _SEEDREAM_BATCH_MAX_TOTAL_IMAGES:
+ raise ExternalProviderCapabilityError(
+ f"{request.model.name} supports at most {_SEEDREAM_BATCH_MAX_TOTAL_IMAGES} images total "
+ f"(reference + init + output), got {total_images}"
+ )
+
opts = request.provider_options or {}
payload: dict[str, object] = {
@@ -99,6 +114,7 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu
raise ExternalProviderRequestError("Seedream response payload was not a JSON object")
generated_images: list[ExternalGeneratedImage] = []
+ item_errors: list[dict[str, object]] = []
data_items = body.get("data")
if not isinstance(data_items, list):
raise ExternalProviderRequestError("Seedream response payload missing image data")
@@ -106,8 +122,13 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu
for item in data_items:
if not isinstance(item, dict):
continue
- # Items may be error objects for failed images in batch
+ # Items may be error objects for failed images in batch — collect rather than discard
+ # so partial-failure causes (e.g., content filter) are visible to the caller.
if "error" in item:
+ error_payload = item["error"]
+ item_errors.append(
+ error_payload if isinstance(error_payload, dict) else {"message": str(error_payload)}
+ )
continue
encoded = item.get("b64_json")
if not encoded:
@@ -116,12 +137,28 @@ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResu
generated_images.append(ExternalGeneratedImage(image=image, seed=request.seed))
if not generated_images:
+ if item_errors:
+ first = item_errors[0]
+ message = first.get("message") if isinstance(first, dict) else None
+ raise ExternalProviderRequestError(
+ f"Seedream returned no images. Provider reported: {message or item_errors}"
+ )
raise ExternalProviderRequestError("Seedream response contained no images")
+ provider_metadata: dict[str, object] = {"model": model_id}
+ if item_errors:
+ provider_metadata["partial_failures"] = item_errors
+ self._logger.warning(
+ "Seedream returned %d image(s) with %d partial failure(s): %s",
+ len(generated_images),
+ len(item_errors),
+ item_errors,
+ )
+
return ExternalGenerationResult(
images=generated_images,
seed_used=request.seed,
- provider_metadata={"model": model_id},
+ provider_metadata=provider_metadata,
)
diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py
index 3028190c165..8d62c85133d 100644
--- a/invokeai/backend/model_manager/starter_models.py
+++ b/invokeai/backend/model_manager/starter_models.py
@@ -1335,6 +1335,7 @@ def _gemini_3_resolution_presets(
supports_negative_prompt=False,
supports_reference_images=True,
supports_seed=True,
+ max_reference_images=3,
max_images_per_request=4,
allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS,
aspect_ratio_sizes={
diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json
index ef419f02fff..5746b4e7fff 100644
--- a/invokeai/frontend/web/openapi.json
+++ b/invokeai/frontend/web/openapi.json
@@ -6479,6 +6479,11 @@
"tags": ["app"],
"summary": "Set External Provider Config",
"operationId": "set_external_provider_config",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
"parameters": [
{
"name": "provider_id",
@@ -6530,6 +6535,11 @@
"tags": ["app"],
"summary": "Reset External Provider Config",
"operationId": "reset_external_provider_config",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
"parameters": [
{
"name": "provider_id",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c164d1dafe1..5b1c9f8870f 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1746,6 +1746,34 @@
"staged": "Staged",
"resolution": "Resolution",
"imageSize": "Image Size",
+ "quality": "Quality",
+ "qualityOptions": {
+ "auto": "Auto",
+ "high": "High",
+ "medium": "Medium",
+ "low": "Low"
+ },
+ "background": "Background",
+ "backgroundOptions": {
+ "auto": "Auto",
+ "transparent": "Transparent",
+ "opaque": "Opaque"
+ },
+ "inputFidelity": "Input Fidelity",
+ "inputFidelityOptions": {
+ "default": "Default",
+ "low": "Low",
+ "high": "High"
+ },
+ "temperature": "Temperature",
+ "thinkingLevel": "Thinking Level",
+ "thinkingLevelOptions": {
+ "default": "Default",
+ "minimal": "Minimal",
+ "high": "High"
+ },
+ "watermark": "Watermark",
+ "optimizePrompt": "Optimize Prompt",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade."
},
"dynamicPrompts": {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
index 6955b621caf..b8fbb08c020 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
@@ -1,5 +1,6 @@
import { Grid, GridItem } from '@invoke-ai/ui-library';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled';
import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { memo } from 'react';
@@ -21,6 +22,10 @@ const addResizedControlLayerFromImageDndTargetData = newCanvasEntityFromImageDnd
export const CanvasDropArea = memo(() => {
const { t } = useTranslation();
const isBusy = useCanvasIsBusy();
+ const isRasterLayerEnabled = useIsEntityTypeEnabled('raster_layer');
+ const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer');
+ const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance');
+ const isInpaintMaskEnabled = useIsEntityTypeEnabled('inpaint_mask');
return (
<>
@@ -39,7 +44,7 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addRasterLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newRasterLayer')}
- isDisabled={isBusy}
+ isDisabled={isBusy || !isRasterLayerEnabled}
/>
@@ -47,7 +52,7 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newControlLayer')}
- isDisabled={isBusy}
+ isDisabled={isBusy || !isControlLayerEnabled}
/>
@@ -55,7 +60,7 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addRegionalGuidanceReferenceImageFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newRegionalReferenceImage')}
- isDisabled={isBusy}
+ isDisabled={isBusy || !isRegionalGuidanceEnabled}
/>
@@ -63,7 +68,7 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addInpaintMaskFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newInpaintMask')}
- isDisabled={isBusy}
+ isDisabled={isBusy || !isInpaintMaskEnabled}
/>
@@ -71,7 +76,7 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addResizedControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newResizedControlLayer')}
- isDisabled={isBusy}
+ isDisabled={isBusy || !isControlLayerEnabled}
/>
diff --git a/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx b/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx
index 1ec27ef809d..d6e983d597d 100644
--- a/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/External/GeminiProviderOptions.tsx
@@ -62,9 +62,9 @@ export const GeminiProviderOptions = memo(() => {
icon={}
iconSize="0.75rem"
>
-
-
-
+
+
+
>
diff --git a/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx b/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx
index f4eba05d84b..c78b9e791db 100644
--- a/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/External/OpenAIProviderOptions.tsx
@@ -43,10 +43,10 @@ export const OpenAIProviderOptions = memo(() => {
{t('parameters.quality', 'Quality')}
} iconSize="0.75rem">
-
-
-
-
+
+
+
+
@@ -58,9 +58,9 @@ export const OpenAIProviderOptions = memo(() => {
icon={}
iconSize="0.75rem"
>
-
-
-
+
+
+
@@ -72,9 +72,9 @@ export const OpenAIProviderOptions = memo(() => {
icon={}
iconSize="0.75rem"
>
-
-
-
+
+
+
>
diff --git a/invokeai/frontend/web/src/features/parameters/components/External/externalProviderOptionsI18n.test.ts b/invokeai/frontend/web/src/features/parameters/components/External/externalProviderOptionsI18n.test.ts
new file mode 100644
index 00000000000..8dfdec2d89e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/External/externalProviderOptionsI18n.test.ts
@@ -0,0 +1,53 @@
+import { readFileSync } from 'node:fs';
+
+import { describe, expect, it } from 'vitest';
+
+const FILES = ['OpenAIProviderOptions.tsx', 'GeminiProviderOptions.tsx', 'SeedreamProviderOptions.tsx'] as const;
+
+const LABEL_RE = /]*>([\s\S]*?)<\/FormLabel>/g;
+const OPTION_RE = /