Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

370 lines
14 KiB
Python

from __future__ import annotations
import dataclasses
import time
from logging import Logger
from typing import TYPE_CHECKING
from PIL import Image
from PIL.Image import Image as PILImageType
from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderNotConfiguredError,
ExternalProviderNotFoundError,
ExternalProviderRateLimitError,
)
from invokeai.app.services.external_generation.external_generation_base import (
ExternalGenerationServiceBase,
ExternalProvider,
)
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalProviderStatus,
)
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalImageSize
from invokeai.backend.model_manager.starter_models import STARTER_MODELS
if TYPE_CHECKING:
from invokeai.app.services.model_records import ModelRecordServiceBase
class ExternalGenerationService(ExternalGenerationServiceBase):
def __init__(
self,
providers: dict[str, ExternalProvider],
logger: Logger,
record_store: ModelRecordServiceBase | None = None,
) -> None:
self._providers = providers
self._logger = logger
self._record_store = record_store
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
provider = self._providers.get(request.model.provider_id)
if provider is None:
raise ExternalProviderNotFoundError(f"No external provider registered for '{request.model.provider_id}'")
if not provider.is_configured():
raise ExternalProviderNotConfiguredError(f"Provider '{request.model.provider_id}' is missing credentials")
request = self._refresh_model_capabilities(request)
resize_to_original_inpaint_size = _get_resize_target_for_inpaint(request)
request = self._bucket_request(request)
request = self._drop_unsupported_capabilities(request)
self._validate_request(request)
result = self._generate_with_retry(provider, request)
if resize_to_original_inpaint_size is None:
return result
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()}
def _validate_request(self, request: ExternalGenerationRequest) -> None:
capabilities = request.model.capabilities
self._logger.debug(
"Validating external request provider=%s model=%s mode=%s supported=%s",
request.model.provider_id,
request.model.provider_model_id,
request.mode,
capabilities.modes,
)
if request.mode not in capabilities.modes:
raise ExternalProviderCapabilityError(f"Mode '{request.mode}' 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}")
if capabilities.max_reference_images is not None:
if len(request.reference_images) > capabilities.max_reference_images:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports at most {capabilities.max_reference_images} reference images"
)
if capabilities.max_images_per_request is not None and request.num_images > capabilities.max_images_per_request:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports at most {capabilities.max_images_per_request} images per request"
)
if capabilities.max_image_size is not None:
if request.width > capabilities.max_image_size.width or request.height > capabilities.max_image_size.height:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports a maximum size of {capabilities.max_image_size.width}x{capabilities.max_image_size.height}"
)
if capabilities.allowed_aspect_ratios:
aspect_ratio = _format_aspect_ratio(request.width, request.height)
if aspect_ratio not in capabilities.allowed_aspect_ratios:
size_ratio = None
if capabilities.aspect_ratio_sizes:
size_ratio = _ratio_for_size(request.width, request.height, capabilities.aspect_ratio_sizes)
if size_ratio is None or size_ratio not in capabilities.allowed_aspect_ratios:
ratio_label = size_ratio or aspect_ratio
raise ExternalProviderCapabilityError(
f"{request.model.name} does not support aspect ratio {ratio_label}"
)
required_modes = capabilities.input_image_required_for or ["img2img", "inpaint"]
if request.mode in required_modes and request.init_image is None:
raise ExternalProviderCapabilityError(
f"Mode '{request.mode}' requires an init image for {request.model.name}"
)
if request.mode == "inpaint" and request.mask_image is None:
raise ExternalProviderCapabilityError(
f"Mode '{request.mode}' requires a mask image for {request.model.name}"
)
def _drop_unsupported_capabilities(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
"""Silently drop request fields the selected model does not support so workflow-editor runs don't fail
when users wire them in regardless."""
capabilities = request.model.capabilities
updates: dict[str, object] = {}
if request.seed is not None and not capabilities.supports_seed:
self._logger.debug(
"Dropping seed for %s: model does not support seed control",
request.model.name,
)
updates["seed"] = None
if updates:
return dataclasses.replace(request, **updates)
return request
def _refresh_model_capabilities(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
if self._record_store is None:
return request
try:
record = self._record_store.get_model(request.model.key)
except Exception:
record = None
if not isinstance(record, ExternalApiModelConfig):
return request
if record.key != request.model.key:
return request
if record.provider_id != request.model.provider_id:
return request
if record.provider_model_id != request.model.provider_model_id:
return request
record = _apply_starter_overrides(record)
if record == request.model:
return request
return ExternalGenerationRequest(
model=record,
mode=request.mode,
prompt=request.prompt,
seed=request.seed,
num_images=request.num_images,
width=request.width,
height=request.height,
image_size=request.image_size,
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:
capabilities = request.model.capabilities
if not capabilities.allowed_aspect_ratios:
return request
aspect_ratio = _format_aspect_ratio(request.width, request.height)
size = None
if capabilities.aspect_ratio_sizes:
size = capabilities.aspect_ratio_sizes.get(aspect_ratio)
if size is not None:
if request.width == size.width and request.height == size.height:
return request
return self._bucket_to_size(request, size.width, size.height, aspect_ratio)
if aspect_ratio in capabilities.allowed_aspect_ratios:
return request
if not capabilities.aspect_ratio_sizes:
return request
closest = _select_closest_ratio(
request.width,
request.height,
capabilities.allowed_aspect_ratios,
)
if closest is None:
return request
size = capabilities.aspect_ratio_sizes.get(closest)
if size is None:
return request
return self._bucket_to_size(request, size.width, size.height, closest)
def _bucket_to_size(
self,
request: ExternalGenerationRequest,
width: int,
height: int,
ratio: str,
) -> ExternalGenerationRequest:
self._logger.info(
"Bucketing external request provider=%s model=%s %sx%s -> %sx%s (ratio %s)",
request.model.provider_id,
request.model.provider_model_id,
request.width,
request.height,
width,
height,
ratio,
)
return ExternalGenerationRequest(
model=request.model,
mode=request.mode,
prompt=request.prompt,
seed=request.seed,
num_images=request.num_images,
width=width,
height=height,
image_size=request.image_size,
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,
)
def _format_aspect_ratio(width: int, height: int) -> str:
divisor = _gcd(width, height)
return f"{width // divisor}:{height // divisor}"
def _select_closest_ratio(width: int, height: int, ratios: list[str]) -> str | None:
ratio = width / height
parsed: list[tuple[str, float]] = []
for value in ratios:
parsed_ratio = _parse_ratio(value)
if parsed_ratio is not None:
parsed.append((value, parsed_ratio))
if not parsed:
return None
return min(parsed, key=lambda item: abs(item[1] - ratio))[0]
def _ratio_for_size(width: int, height: int, sizes: dict[str, ExternalImageSize]) -> str | None:
for ratio, size in sizes.items():
if size.width == width and size.height == height:
return ratio
return None
def _parse_ratio(value: str) -> float | None:
if ":" not in value:
return None
left, right = value.split(":", 1)
try:
numerator = float(left)
denominator = float(right)
except ValueError:
return None
if denominator == 0:
return None
return numerator / denominator
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b
return a
def _resize_image(image: PILImageType | None, width: int, height: int, mode: str) -> PILImageType | None:
if image is None:
return None
if image.width == width and image.height == height:
return image
return image.convert(mode).resize((width, height), Image.Resampling.LANCZOS)
def _get_resize_target_for_inpaint(request: ExternalGenerationRequest) -> tuple[int, int] | None:
if request.mode != "inpaint" or request.init_image is None:
return None
return request.init_image.width, request.init_image.height
def _resize_result_images(result: ExternalGenerationResult, width: int, height: int) -> ExternalGenerationResult:
resized_images = [
ExternalGeneratedImage(
image=generated.image
if generated.image.width == width and generated.image.height == height
else generated.image.resize((width, height), Image.Resampling.LANCZOS),
seed=generated.seed,
)
for generated in result.images
]
return ExternalGenerationResult(
images=resized_images,
seed_used=result.seed_used,
provider_request_id=result.provider_request_id,
provider_metadata=result.provider_metadata,
content_filters=result.content_filters,
)
def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig:
source = model.source or f"external://{model.provider_id}/{model.provider_model_id}"
starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None)
if starter_match is None:
return model
updates: dict[str, object] = {}
if starter_match.capabilities is not None:
updates["capabilities"] = starter_match.capabilities
if starter_match.default_settings is not None:
updates["default_settings"] = starter_match.default_settings
if not updates:
return model
return model.model_copy(update=updates)