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

249 lines
9.7 KiB
Python

from __future__ import annotations
import requests
from invokeai.app.services.external_generation.errors import (
ExternalProviderRateLimitError,
ExternalProviderRequestError,
)
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
)
from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64
class GeminiProvider(ExternalProvider):
provider_id = "gemini"
_SYSTEM_INSTRUCTION = (
"You are an image generation model. Always respond with an image based on the user's prompt. "
"Do not return text-only responses. If the user input is not an edit instruction, "
"interpret it as a request to create a new image."
)
def is_configured(self) -> bool:
return bool(self._app_config.external_gemini_api_key)
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
api_key = self._app_config.external_gemini_api_key
if not api_key:
raise ExternalProviderRequestError("Gemini API key is not configured")
base_url = (self._app_config.external_gemini_base_url or "https://generativelanguage.googleapis.com").rstrip(
"/"
)
if not base_url.endswith("/v1") and not base_url.endswith("/v1beta"):
base_url = f"{base_url}/v1beta"
model_id = request.model.provider_model_id.removeprefix("models/")
endpoint = f"{base_url}/models/{model_id}:generateContent"
request_parts: list[dict[str, object]] = []
if request.init_image is not None:
request_parts.append(
{
"inlineData": {
"mimeType": "image/png",
"data": encode_image_base64(request.init_image),
}
}
)
request_parts.append({"text": request.prompt})
for reference in request.reference_images:
request_parts.append(
{
"inlineData": {
"mimeType": "image/png",
"data": encode_image_base64(reference.image),
}
}
)
opts = request.provider_options or {}
generation_config: dict[str, object] = {
"candidateCount": request.num_images,
"responseModalities": ["IMAGE"],
}
if "temperature" in opts:
generation_config["temperature"] = opts["temperature"]
aspect_ratio = _select_aspect_ratio(
request.width,
request.height,
request.model.capabilities.allowed_aspect_ratios,
)
uses_image_config = request.model.capabilities.resolution_presets is not None
if uses_image_config:
image_config: dict[str, str] = {}
if aspect_ratio is not None:
image_config["aspectRatio"] = aspect_ratio
if request.image_size is not None:
image_config["imageSize"] = request.image_size
if image_config:
generation_config["imageConfig"] = image_config
system_instruction = self._SYSTEM_INSTRUCTION
if request.init_image is not None:
system_instruction = (
f"{system_instruction} An input image is provided. "
"Treat the prompt as an edit instruction and modify the image accordingly. "
"Do not return the original image unchanged."
)
if not uses_image_config and aspect_ratio is not None:
system_instruction = f"{system_instruction} Use an aspect ratio of {aspect_ratio}."
payload: dict[str, object] = {
"systemInstruction": {"parts": [{"text": system_instruction}]},
"contents": [{"role": "user", "parts": request_parts}],
"generationConfig": generation_config,
}
if "thinking_level" in opts:
payload["thinkingConfig"] = {"thinkingLevel": opts["thinking_level"].upper()}
response = requests.post(
endpoint,
params={"key": api_key},
json=payload,
timeout=120,
)
if not response.ok:
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("retry-after"))
raise ExternalProviderRateLimitError(
f"Gemini rate limit exceeded. {f'Retry after {retry_after:.0f}s.' if retry_after else 'Please try again later.'}",
retry_after=retry_after,
)
raise ExternalProviderRequestError(
f"Gemini request failed with status {response.status_code} for model '{model_id}': {response.text}"
)
data = response.json()
if not isinstance(data, dict):
raise ExternalProviderRequestError("Gemini response payload was not a JSON object")
images: list[ExternalGeneratedImage] = []
text_parts: list[str] = []
finish_messages: list[str] = []
candidates = data.get("candidates")
if not isinstance(candidates, list):
raise ExternalProviderRequestError("Gemini response payload missing candidates")
for candidate in candidates:
if not isinstance(candidate, dict):
continue
finish_message = candidate.get("finishMessage")
finish_reason = candidate.get("finishReason")
if isinstance(finish_message, str):
finish_messages.append(finish_message)
elif isinstance(finish_reason, str):
finish_messages.append(f"Finish reason: {finish_reason}")
for part in _iter_response_parts(candidate):
inline_data = part.get("inline_data") or part.get("inlineData")
if isinstance(inline_data, dict):
encoded = inline_data.get("data")
if encoded:
image = decode_image_base64(encoded)
images.append(ExternalGeneratedImage(image=image, seed=request.seed))
continue
file_data = part.get("fileData") or part.get("file_data")
if isinstance(file_data, dict):
file_uri = file_data.get("fileUri") or file_data.get("file_uri")
if isinstance(file_uri, str) and file_uri:
raise ExternalProviderRequestError(
f"Gemini returned fileUri instead of inline image data: {file_uri}"
)
text = part.get("text")
if isinstance(text, str):
text_parts.append(text)
if not images:
self._logger.error("Gemini response contained no images: %s", data)
detail = ""
if finish_messages:
combined = " ".join(message.strip() for message in finish_messages if message.strip())
if combined:
detail = f" Response status: {combined[:500]}"
elif text_parts:
combined = " ".join(text_parts).strip()
if combined:
detail = f" Response text: {combined[:500]}"
raise ExternalProviderRequestError(f"Gemini response contained no images.{detail}")
return ExternalGenerationResult(
images=images,
seed_used=request.seed,
provider_metadata={"model": request.model.provider_model_id},
)
def _iter_response_parts(candidate: dict[str, object]) -> list[dict[str, object]]:
content = candidate.get("content")
if isinstance(content, dict):
content_parts = content.get("parts")
if isinstance(content_parts, list):
return [part for part in content_parts if isinstance(part, dict)]
contents = candidate.get("contents")
if isinstance(contents, list):
parts: list[dict[str, object]] = []
for item in contents:
if not isinstance(item, dict):
continue
item_parts = item.get("parts")
if isinstance(item_parts, list):
parts.extend([part for part in item_parts if isinstance(part, dict)])
if parts:
return parts
return []
def _select_aspect_ratio(width: int, height: int, allowed: list[str] | None) -> str | None:
if width <= 0 or height <= 0:
return None
ratio = width / height
default_ratio = _format_aspect_ratio(width, height)
if not allowed:
return default_ratio
parsed = [(value, _parse_ratio(value)) for value in allowed]
filtered = [(value, parsed_ratio) for value, parsed_ratio in parsed if parsed_ratio is not None]
if not filtered:
return default_ratio
return min(filtered, key=lambda item: abs(item[1] - ratio))[0]
def _format_aspect_ratio(width: int, height: int) -> str | None:
if width <= 0 or height <= 0:
return None
divisor = _gcd(width, height)
return f"{width // divisor}:{height // divisor}"
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 _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b
return a