Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions invokeai/app/api/routers/app_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 39 additions & 2 deletions invokeai/app/services/external_generation/providers/seedream.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import requests

from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderRateLimitError,
ExternalProviderRequestError,
)
Expand All @@ -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"
Expand All @@ -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] = {
Expand Down Expand Up @@ -99,15 +114,21 @@ 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")

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:
Expand All @@ -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,
)


Expand Down
1 change: 1 addition & 0 deletions invokeai/backend/model_manager/starter_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
10 changes: 10 additions & 0 deletions invokeai/frontend/web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6479,6 +6479,11 @@
"tags": ["app"],
"summary": "Set External Provider Config",
"operationId": "set_external_provider_config",
"security": [
{
"HTTPBearer": []
}
],
"parameters": [
{
"name": "provider_id",
Expand Down Expand Up @@ -6530,6 +6535,11 @@
"tags": ["app"],
"summary": "Reset External Provider Config",
"operationId": "reset_external_provider_config",
"security": [
{
"HTTPBearer": []
}
],
"parameters": [
{
"name": "provider_id",
Expand Down
28 changes: 28 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <LinkComponent>account settings</LinkComponent> to upgrade."
},
"dynamicPrompts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<>
Expand All @@ -39,39 +44,39 @@ export const CanvasDropArea = memo(() => {
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addRasterLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newRasterLayer')}
isDisabled={isBusy}
isDisabled={isBusy || !isRasterLayerEnabled}
/>
</GridItem>
<GridItem position="relative" colSpan={3}>
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newControlLayer')}
isDisabled={isBusy}
isDisabled={isBusy || !isControlLayerEnabled}
/>
</GridItem>
<GridItem position="relative" colSpan={2}>
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addRegionalGuidanceReferenceImageFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newRegionalReferenceImage')}
isDisabled={isBusy}
isDisabled={isBusy || !isRegionalGuidanceEnabled}
/>
</GridItem>
<GridItem position="relative" colSpan={2}>
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addInpaintMaskFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newInpaintMask')}
isDisabled={isBusy}
isDisabled={isBusy || !isInpaintMaskEnabled}
/>
</GridItem>
<GridItem position="relative" colSpan={2}>
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addResizedControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newResizedControlLayer')}
isDisabled={isBusy}
isDisabled={isBusy || !isControlLayerEnabled}
/>
</GridItem>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export const GeminiProviderOptions = memo(() => {
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="">Default</option>
<option value="minimal">Minimal</option>
<option value="high">High</option>
<option value="">{t('parameters.thinkingLevelOptions.default', 'Default')}</option>
<option value="minimal">{t('parameters.thinkingLevelOptions.minimal', 'Minimal')}</option>
<option value="high">{t('parameters.thinkingLevelOptions.high', 'High')}</option>
</Select>
</FormControl>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ export const OpenAIProviderOptions = memo(() => {
<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>
<option value="auto">{t('parameters.qualityOptions.auto', 'Auto')}</option>
<option value="high">{t('parameters.qualityOptions.high', 'High')}</option>
<option value="medium">{t('parameters.qualityOptions.medium', 'Medium')}</option>
<option value="low">{t('parameters.qualityOptions.low', 'Low')}</option>
</Select>
</FormControl>
<FormControl>
Expand All @@ -58,9 +58,9 @@ export const OpenAIProviderOptions = memo(() => {
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="auto">Auto</option>
<option value="transparent">Transparent</option>
<option value="opaque">Opaque</option>
<option value="auto">{t('parameters.backgroundOptions.auto', 'Auto')}</option>
<option value="transparent">{t('parameters.backgroundOptions.transparent', 'Transparent')}</option>
<option value="opaque">{t('parameters.backgroundOptions.opaque', 'Opaque')}</option>
</Select>
</FormControl>
<FormControl>
Expand All @@ -72,9 +72,9 @@ export const OpenAIProviderOptions = memo(() => {
icon={<PiCaretDownBold />}
iconSize="0.75rem"
>
<option value="">Default</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="">{t('parameters.inputFidelityOptions.default', 'Default')}</option>
<option value="low">{t('parameters.inputFidelityOptions.low', 'Low')}</option>
<option value="high">{t('parameters.inputFidelityOptions.high', 'High')}</option>
</Select>
</FormControl>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = /<FormLabel[^>]*>([\s\S]*?)<\/FormLabel>/g;
const OPTION_RE = /<option\b[^>]*>([\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 <FormLabel> child is a t(...) expression`, () => {
const offenders = collectOffenders(source, LABEL_RE);
expect(
offenders,
`Found <FormLabel> 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 <option> child is a t(...) expression`, () => {
const offenders = collectOffenders(source, OPTION_RE);
expect(
offenders,
`Found <option> nodes whose visible text is a raw literal. ` +
`Select option labels under External/ must be wrapped in t(...) so they translate ` +
`alongside their <FormLabel>. Offenders:\n${offenders.join('\n')}`
).toEqual([]);
});
}
});
Loading
Loading