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')} @@ -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 = /]*>([\s\S]*?)<\/option>/g; + +const isTranslatedExpression = (inner: string): boolean => { + const trimmed = inner.trim(); + if (trimmed === '') { + return true; + } + return /^\{\s*t\s*\(/.test(trimmed) && /\)\s*\}$/.test(trimmed); +}; + +const collectOffenders = (source: string, re: RegExp): string[] => { + const offenders: string[] = []; + for (const match of source.matchAll(re)) { + const inner = match[1] ?? ''; + if (!isTranslatedExpression(inner)) { + offenders.push(match[0]); + } + } + return offenders; +}; + +describe('External provider option components are fully localised', () => { + for (const file of FILES) { + const source = readFileSync(new URL(`./${file}`, import.meta.url), 'utf8'); + + it(`${file}: every child is a t(...) expression`, () => { + const offenders = collectOffenders(source, LABEL_RE); + expect( + offenders, + `Found nodes whose visible text is not wrapped in t(...). ` + + `External provider option labels must be localised so non-English users see translated text. ` + + `Offenders:\n${offenders.join('\n')}` + ).toEqual([]); + }); + + it(`${file}: every