From 243fbadb4d61036111d938b8a2faf074b17c83ca Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 14 May 2026 22:39:23 +0200 Subject: [PATCH 1/5] fix(external-providers): admin guard, validation, locale keys - Require AdminUserOrDefault on POST/DELETE /external_providers/config/{id} so non-admins in multiuser mode can no longer set/reset shared credentials - Reject Seedream batch requests where references + init + outputs > 15 before posting, surfacing ExternalProviderCapabilityError instead of a provider-side 400 - Surface Seedream batch item errors via provider_metadata.partial_failures and raise when every item failed, instead of silently dropping filtered results - Set max_reference_images=3 on Qwen Image Edit Max so the central validator enforces the documented limit before hitting DashScope - Add missing parameters.* locale keys (quality, background, inputFidelity, temperature, thinkingLevel, watermark, optimizePrompt) so the OpenAI, Gemini, and Seedream option panels render their labels without fallbacks --- invokeai/app/api/routers/app_info.py | 2 + .../external_generation/providers/seedream.py | 39 ++++++++++++- .../backend/model_manager/starter_models.py | 1 + invokeai/frontend/web/public/locales/en.json | 7 +++ tests/app/routers/test_app_info.py | 46 +++++++++++++++ .../test_external_generation_service.py | 27 +++++++++ .../test_seedream_provider.py | 56 ++++++++++++++++++- 7 files changed, 174 insertions(+), 4 deletions(-) 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..cb3a6b681e7 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,11 @@ 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 +135,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 306b1482344..525ffacb33e 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/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index d99bb04a631..08cb4e568e0 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1746,6 +1746,13 @@ "staged": "Staged", "resolution": "Resolution", "imageSize": "Image Size", + "quality": "Quality", + "background": "Background", + "inputFidelity": "Input Fidelity", + "temperature": "Temperature", + "thinkingLevel": "Thinking Level", + "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/tests/app/routers/test_app_info.py b/tests/app/routers/test_app_info.py index 2f6d0217044..da493cee457 100644 --- a/tests/app/routers/test_app_info.py +++ b/tests/app/routers/test_app_info.py @@ -59,6 +59,7 @@ def test_external_provider_config_update_and_reset(monkeypatch: Any, mock_invoke mock_model_manager.install = mock_install mock_invoker.services.model_manager = mock_model_manager monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) for provider_id in ("gemini", "openai"): response = client.delete(f"/api/v1/app/external_providers/config/{provider_id}") @@ -139,6 +140,7 @@ def test_reset_external_provider_config_removes_provider_models( mock_invoker.services.model_manager = mock_model_manager monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) response = client.delete("/api/v1/app/external_providers/config/openai") @@ -169,6 +171,7 @@ def test_set_external_provider_config_clears_provider_models_when_api_key_remove mock_invoker.services.model_manager = mock_model_manager monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) response = client.post("/api/v1/app/external_providers/config/openai", json={"api_key": " "}) @@ -286,5 +289,48 @@ def test_update_runtime_config_rejects_non_admin_users( assert response.json()["detail"] == "Admin privileges required" +@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"]) +def test_set_external_provider_config_rejects_non_admin_users( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True) + monkeypatch.setattr( + "invokeai.app.api.auth_dependencies.verify_token", + lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False), + ) + monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True))) + + response = client.post( + f"/api/v1/app/external_providers/config/{provider_id}", + json={"api_key": "non-admin-attempt"}, + headers={"Authorization": "Bearer non-admin-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin privileges required" + + +@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"]) +def test_reset_external_provider_config_rejects_non_admin_users( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True) + monkeypatch.setattr( + "invokeai.app.api.auth_dependencies.verify_token", + lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False), + ) + monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True))) + + response = client.delete( + f"/api/v1/app/external_providers/config/{provider_id}", + headers={"Authorization": "Bearer non-admin-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin privileges required" + + def _get_provider_config(payload: list[dict[str, Any]], provider_id: str) -> dict[str, Any]: return next(item for item in payload if item["provider_id"] == provider_id) diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py index 89c1a6db315..aed641fe602 100644 --- a/tests/app/services/external_generation/test_external_generation_service.py +++ b/tests/app/services/external_generation/test_external_generation_service.py @@ -248,3 +248,30 @@ def test_generate_resizes_inpaint_result_to_original_init_size() -> None: assert response.images[0].image.width == request.init_image.width assert response.images[0].image.height == request.init_image.height assert response.images[0].seed == 1 + + +def test_qwen_image_edit_max_enforces_three_reference_images() -> None: + from invokeai.backend.model_manager.starter_models import alibabacloud_qwen_image_edit_max + + capabilities = alibabacloud_qwen_image_edit_max.capabilities + assert capabilities is not None + assert capabilities.max_reference_images == 3 + + model = ExternalApiModelConfig( + key="qwen_image_edit_max", + name="Qwen Image Edit Max", + provider_id="alibabacloud", + provider_model_id="qwen-image-edit-max", + capabilities=capabilities, + ) + request = _build_request( + model=model, + reference_images=[ExternalReferenceImage(image=_make_image()) for _ in range(4)], + ) + provider = DummyProvider("alibabacloud", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService({"alibabacloud": provider}, logging.getLogger("test")) + + with pytest.raises(ExternalProviderCapabilityError, match="supports at most 3 reference images"): + service.generate(request) + + assert provider.last_request is None diff --git a/tests/app/services/external_generation/test_seedream_provider.py b/tests/app/services/external_generation/test_seedream_provider.py index 1981ee0d0b9..259540e34f7 100644 --- a/tests/app/services/external_generation/test_seedream_provider.py +++ b/tests/app/services/external_generation/test_seedream_provider.py @@ -4,7 +4,10 @@ from PIL import Image from invokeai.app.services.config.config_default import InvokeAIAppConfig -from invokeai.app.services.external_generation.errors import ExternalProviderRequestError +from invokeai.app.services.external_generation.errors import ( + ExternalProviderCapabilityError, + ExternalProviderRequestError, +) from invokeai.app.services.external_generation.external_generation_common import ( ExternalGenerationRequest, ExternalReferenceImage, @@ -276,7 +279,7 @@ def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyRespons assert captured["url"] == "https://proxy.seedream/api/v3/images/generations" -def test_seedream_batch_skips_error_items(monkeypatch: pytest.MonkeyPatch) -> None: +def test_seedream_batch_surfaces_partial_failures(monkeypatch: pytest.MonkeyPatch) -> None: config = InvokeAIAppConfig(external_seedream_api_key="seedream-key") provider = SeedreamProvider(config, logging.getLogger("test")) model = _build_model("seedream-4-5-251128") @@ -300,3 +303,52 @@ def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyRespons result = provider.generate(request) assert len(result.images) == 2 + assert result.provider_metadata is not None + partial_failures = result.provider_metadata.get("partial_failures") + assert isinstance(partial_failures, list) and len(partial_failures) == 1 + assert partial_failures[0] == {"code": "content_filter", "message": "filtered"} + + +def test_seedream_batch_all_items_failed_raises(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_seedream_api_key="seedream-key") + provider = SeedreamProvider(config, logging.getLogger("test")) + model = _build_model("seedream-4-5-251128") + request = _build_request(model, num_images=2) + + def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse: + return DummyResponse( + ok=True, + json_data={ + "data": [ + {"error": {"code": "content_filter", "message": "filtered"}}, + {"error": {"code": "content_filter", "message": "filtered"}}, + ] + }, + ) + + monkeypatch.setattr("requests.post", fake_post) + + with pytest.raises(ExternalProviderRequestError, match="filtered"): + provider.generate(request) + + +def test_seedream_rejects_combined_reference_and_output_count(monkeypatch: pytest.MonkeyPatch) -> None: + config = InvokeAIAppConfig(external_seedream_api_key="seedream-key") + provider = SeedreamProvider(config, logging.getLogger("test")) + model = _build_model("seedream-4-5-251128") + references = [ExternalReferenceImage(image=_make_image("red")) for _ in range(14)] + request = _build_request(model, num_images=15, reference_images=references) + + posted = False + + def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse: + nonlocal posted + posted = True + return DummyResponse(ok=True, json_data={"data": []}) + + monkeypatch.setattr("requests.post", fake_post) + + with pytest.raises(ExternalProviderCapabilityError, match="15 images total"): + provider.generate(request) + + assert posted is False From d4643e1e07834a24637c7e1a91fca961bee2ec64 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 15 May 2026 00:43:43 +0200 Subject: [PATCH 2/5] i18n(external): localise OpenAI/Gemini select option labels Wrap the visible