diff --git a/nanobot/agent/tools/image_generation.py b/nanobot/agent/tools/image_generation.py index f9d4056dc..eedbecadc 100644 --- a/nanobot/agent/tools/image_generation.py +++ b/nanobot/agent/tools/image_generation.py @@ -19,6 +19,7 @@ from nanobot.config.schema import Base from nanobot.providers.image_generation import ( AIHubMixImageGenerationClient, ImageGenerationError, + MiniMaxImageGenerationClient, OpenRouterImageGenerationClient, ) from nanobot.utils.artifacts import ( @@ -117,7 +118,9 @@ class ImageGenerationTool(Tool): def _provider_config(self) -> ProviderConfig | None: return self.provider_configs.get(self.config.provider) - def _provider_client(self) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | None: + def _provider_client( + self, + ) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | MiniMaxImageGenerationClient | None: provider = self._provider_config() kwargs = { "api_key": provider.api_key if provider else None, @@ -129,6 +132,8 @@ class ImageGenerationTool(Tool): return OpenRouterImageGenerationClient(**kwargs) if self.config.provider == "aihubmix": return AIHubMixImageGenerationClient(**kwargs) + if self.config.provider == "minimax": + return MiniMaxImageGenerationClient(**kwargs) return None def _missing_api_key_error(self) -> str: @@ -137,6 +142,8 @@ class ImageGenerationTool(Tool): return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey." if provider == "aihubmix": return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey." + if provider == "minimax": + return "Error: MiniMax API key is not configured. Set providers.minimax.apiKey." return f"Error: {provider} API key is not configured." def _resolve_reference_image(self, value: str) -> str: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 694205436..bd5e7f453 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -642,6 +642,7 @@ def serve( image_generation_provider_configs={ "openrouter": runtime_config.providers.openrouter, "aihubmix": runtime_config.providers.aihubmix, + "minimax": runtime_config.providers.minimax, }, ) except ValueError as exc: @@ -755,6 +756,7 @@ def _run_gateway( image_generation_provider_configs={ "openrouter": config.providers.openrouter, "aihubmix": config.providers.aihubmix, + "minimax": config.providers.minimax, }, provider_snapshot_loader=load_provider_snapshot, runtime_model_publisher=lambda model, preset: publish_runtime_model_update( diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index bfedb7611..09c58de05 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -66,6 +66,7 @@ class Nanobot: image_generation_provider_configs={ "openrouter": config.providers.openrouter, "aihubmix": config.providers.aihubmix, + "minimax": config.providers.minimax, }, ) return cls(loop) diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index d1e7a1b24..dc74c62f3 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -393,3 +393,136 @@ async def _aihubmix_images_from_payload( for candidate in candidates: await collect(candidate) return images + + +_MINIMAX_TIMEOUT_S = 300.0 + +_MINIMAX_ASPECT_RATIO_SIZES = { + "1:1": "1:1", + "16:9": "16:9", + "4:3": "4:3", + "3:2": "3:2", + "2:3": "2:3", + "3:4": "3:4", + "9:16": "9:16", + "21:9": "21:9", +} + + +class MiniMaxImageGenerationClient: + """Async client for MiniMax image generation API.""" + + def __init__( + self, + *, + api_key: str | None, + api_base: str | None = None, + extra_headers: dict[str, str] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float = _MINIMAX_TIMEOUT_S, + client: httpx.AsyncClient | None = None, + ) -> None: + self.api_key = api_key + self.api_base = _provider_base_url( + "minimax", + api_base, + "https://api.minimaxi.com/v1", + ) + self.extra_headers = extra_headers or {} + self.extra_body = extra_body or {} + self.timeout = timeout + self._client = client + + def _resolve_aspect_ratio(self, aspect_ratio: str | None) -> str: + if aspect_ratio and aspect_ratio in _MINIMAX_ASPECT_RATIO_SIZES: + return _MINIMAX_ASPECT_RATIO_SIZES[aspect_ratio] + return "1:1" + + async def generate( + self, + *, + prompt: str, + model: str, + reference_images: list[str] | None = None, + aspect_ratio: str | None = None, + image_size: str | None = None, + ) -> GeneratedImageResponse: + if not self.api_key: + raise ImageGenerationError( + "MiniMax API key is not configured. Set providers.minimax.apiKey." + ) + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + **self.extra_headers, + } + + body: dict[str, Any] = { + "model": model, + "prompt": prompt, + "response_format": "base64", + } + + resolved_ratio = self._resolve_aspect_ratio(aspect_ratio) + body["aspect_ratio"] = resolved_ratio + + refs = list(reference_images or []) + if refs: + image_refs = [image_path_to_data_url(path) for path in refs] + body["subject_reference"] = [ + {"type": "character", "image_file": ref} for ref in image_refs + ] + + body.update(self.extra_body) + + if self._client is not None: + return await self._generate_with_client(self._client, body, headers) + async with httpx.AsyncClient(timeout=self.timeout) as client: + return await self._generate_with_client(client, body, headers) + + async def _generate_with_client( + self, + client: httpx.AsyncClient, + body: dict[str, Any], + headers: dict[str, str], + ) -> GeneratedImageResponse: + url = f"{self.api_base}/image_generation" + try: + response = await client.post(url, headers=headers, json=body) + except httpx.TimeoutException as exc: + raise ImageGenerationError("MiniMax image generation timed out") from exc + except httpx.RequestError as exc: + raise ImageGenerationError(f"MiniMax image generation request failed: {exc}") from exc + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = response.text[:500] + raise ImageGenerationError(f"MiniMax image generation failed: {detail}") from exc + + payload = response.json() + images = _minimax_images_from_payload(payload) + + if not images: + provider_error = payload.get("error") if isinstance(payload, dict) else None + if provider_error: + raise ImageGenerationError(f"MiniMax returned no images: {provider_error}") + raise ImageGenerationError("MiniMax returned no images for this request") + + return GeneratedImageResponse(images=images, content="", raw=payload) + + +def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]: + """Extract base64 images from MiniMax API response. + + MiniMax returns images in ``data.image_base64`` (list of base64 strings). + """ + images: list[str] = [] + data = payload.get("data") + if not isinstance(data, dict): + return images + for b64 in data.get("image_base64") or []: + if isinstance(b64, str) and b64: + images.append(_b64_png_data_url(b64)) + return images