Skip to content
Closed
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
224 changes: 224 additions & 0 deletions plugins/image_gen/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""MiniMax image generation backend.

Exposes MiniMax's ``image-01`` model as an :class:`ImageGenProvider`.

Requires ``MINIMAX_API_KEY`` in the environment. Endpoint:
``https://api.minimax.io/v1/image_generation``

Selection precedence (first hit wins):

1. ``MINIMAX_IMAGE_MODEL`` env var
2. ``image_gen.minimax.model`` in ``config.yaml``
3. :data:`DEFAULT_MODEL`
"""

from __future__ import annotations

import logging
import os
from typing import Any, Dict, List
from urllib.parse import urlparse

import requests

from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
save_b64_image,
success_response,
)

logger = logging.getLogger(__name__)

API_ENDPOINT = "https://api.minimax.io/v1/image_generation"
DEFAULT_MODEL = "image-01"

_MODELS: Dict[str, Dict[str, Any]] = {
"image-01": {
"display": "Image-01",
"speed": "~10-20s",
"strengths": "Photorealistic, 9 aspect ratios, 512-2048px",
"price": "Token Plan (quota-based)",
},
}

# MiniMax natively supports these aspect ratios
_ASPECT_RATIOS = {"1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"}
Comment on lines +46 to +47

# Allowed hosts for image URL download (SSRF guard)
_ALLOWED_IMAGE_HOSTS = {
"hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com",
"hailuo-image-algeng-data.oss-cn-hangzhou.aliyuncs.com",
"cdn.minimax.io",
}


def _load_config() -> Dict[str, Any]:
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
mm = section.get("minimax") if isinstance(section, dict) else None
return mm if isinstance(mm, dict) else {}
except Exception as exc:
logger.debug("Could not load image_gen.minimax config: %s", exc)
return {}


def _resolve_model() -> str:
env = os.environ.get("MINIMAX_IMAGE_MODEL", "").strip()
if env and env in _MODELS:
return env
cfg = _load_config()
candidate = cfg.get("model") if isinstance(cfg.get("model"), str) else None
if candidate and candidate in _MODELS:
return candidate
return DEFAULT_MODEL


class MiniMaxImageProvider(ImageGenProvider):
"""MiniMax ``image-01`` image generation backend."""

@property
def name(self) -> str:
return "minimax"

@property
def display_name(self) -> str:
return "MiniMax"

def is_available(self) -> bool:
return bool(os.getenv("MINIMAX_API_KEY", "").strip())

def list_models(self) -> List[Dict[str, Any]]:
return [{"id": mid, **meta} for mid, meta in _MODELS.items()]

def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "MiniMax",
"badge": "token-plan",
"tag": "Image-01 — photorealistic, 9 aspect ratios",
"env_vars": [
{
"key": "MINIMAX_API_KEY",
"prompt": "MiniMax API key",
"url": "https://platform.minimax.io/user-center/basic-information/interface-key",
}
],
}

def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
**kwargs: Any,
) -> Dict[str, Any]:
api_key = os.getenv("MINIMAX_API_KEY", "").strip()
if not api_key:
return error_response(
error="MINIMAX_API_KEY not set. Get one at https://platform.minimax.io/",
provider="minimax",
model=DEFAULT_MODEL,
prompt=prompt,
aspect_ratio=aspect_ratio,
)

model = _resolve_model()
# Fall back to 1:1 for any ratio not natively supported
mm_ratio = aspect_ratio if aspect_ratio in _ASPECT_RATIOS else "1:1"
Comment on lines +128 to +129

payload: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": mm_ratio,
"n": 1,
"response_format": "url",
}

try:
resp = requests.post(
API_ENDPOINT,
json=payload,
headers={"Authorization": f"Bearer {api_key}"},
timeout=120,
)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as exc:
Comment on lines +139 to +148
return error_response(
error=f"MiniMax API request failed: {exc}",
provider="minimax",
model=model,
prompt=prompt,
aspect_ratio=mm_ratio,
)
except ValueError as exc:
return error_response(
error=f"MiniMax returned non-JSON response: {exc}",
provider="minimax",
model=model,
prompt=prompt,
aspect_ratio=mm_ratio,
)

base_resp = data.get("base_resp", {})
if base_resp.get("status_code", -1) != 0:
return error_response(
error=f"MiniMax error {base_resp.get('status_code')}: {base_resp.get('status_msg')}",
provider="minimax",
model=model,
prompt=prompt,
aspect_ratio=mm_ratio,
)

image_urls = (data.get("data") or {}).get("image_urls", [])
if not image_urls:
return error_response(
error="MiniMax returned no image URLs",
provider="minimax",
model=model,
prompt=prompt,
aspect_ratio=mm_ratio,
)

image_url = image_urls[0]

# Validate URL before fetching: must be HTTPS and from an allowed host
parsed = urlparse(image_url)
_host_ok = (
parsed.scheme == "https"
and any(
parsed.netloc == h or parsed.netloc.endswith("." + h)
for h in _ALLOWED_IMAGE_HOSTS
)
)

image_path = image_url # fallback: return URL as-is
if _host_ok:
try:
import base64
img_resp = requests.get(image_url, timeout=60)
img_resp.raise_for_status()
b64 = base64.b64encode(img_resp.content).decode()
saved = save_b64_image(b64, prefix="minimax", extension="png")
image_path = str(saved)
except Exception as exc:
logger.debug("MiniMax image download failed, returning URL: %s", exc)
else:
logger.warning(
"MiniMax returned image URL with unexpected host %r — skipping download",
parsed.netloc,
)

return success_response(
image=image_path,
model=model,
prompt=prompt,
aspect_ratio=mm_ratio,
provider="minimax",
)


def register(ctx) -> None:
ctx.register_image_gen_provider(MiniMaxImageProvider())
7 changes: 7 additions & 0 deletions plugins/image_gen/minimax/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: minimax
version: 1.0.0
description: "MiniMax image generation backend (image-01). Supports 9 aspect ratios, 512–2048px output."
author: ayushere
kind: backend
requires_env:
- MINIMAX_API_KEY
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@
"mike@grossmann.at": "ReqX",
"axmaiqiu@gmail.com": "qWaitCrypto",
"44045911+kidonng@users.noreply.github.com": "kidonng",
"44045943+ayushere@users.noreply.github.com": "ayushere",
"ayushere@users.noreply.github.com": "ayushere",
"daniellsmarta@gmail.com": "DanielLSM",
"264291321+v1b3coder@users.noreply.github.com": "v1b3coder",
"silverchris@foxmail.com": "ming1523",
Expand Down
24 changes: 20 additions & 4 deletions tools/image_generation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,10 +897,11 @@ def check_image_generation_requirements() -> bool:
"name": "image_generate",
"description": (
"Generate high-quality images from text prompts. The underlying "
"backend (FAL, OpenAI, etc.) and model are user-configured and not "
"backend (MiniMax, OpenAI, etc.) and model are user-configured and not "
"selectable by the agent. Returns either a URL or an absolute file "
"path in the `image` field; display it with markdown "
"![description](url-or-path) and the gateway will deliver it."
"![description](url-or-path) and the gateway will deliver it. "
"Supports character-consistent generation via subject_reference."
),
"parameters": {
"type": "object",
Expand All @@ -915,6 +916,16 @@ def check_image_generation_requirements() -> bool:
"description": "The aspect ratio of the generated image. 'landscape' is 16:9 wide, 'portrait' is 16:9 tall, 'square' is 1:1.",
"default": DEFAULT_ASPECT_RATIO,
},
"subject_reference": {
"type": "string",
"description": (
"Optional. URL or base64 data URL of a reference portrait image. "
"When provided, the generated image preserves that character's visual "
"identity (face, appearance) across different scenes or prompts. "
"Use a clear front-facing portrait. JPG/PNG, max 10MB. "
"MiniMax backend only."
),
},
},
"required": ["prompt"],
},
Expand Down Expand Up @@ -957,7 +968,7 @@ def _read_configured_image_provider():
return None


def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str, **extra_kwargs):
"""Route the call to a plugin-registered provider when one is selected.

Returns a JSON string on dispatch, or ``None`` to fall through to the
Expand Down Expand Up @@ -1013,6 +1024,7 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
kwargs = {"prompt": prompt, "aspect_ratio": aspect_ratio}
if configured_model:
kwargs["model"] = configured_model
kwargs.update({k: v for k, v in extra_kwargs.items() if v is not None})
result = provider.generate(**kwargs)
except Exception as exc:
logger.warning(
Expand Down Expand Up @@ -1040,10 +1052,14 @@ def _handle_image_generate(args, **kw):
if not prompt:
return tool_error("prompt is required for image generation")
aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO)
subject_reference = args.get("subject_reference")

# Route to a plugin-registered provider if one is active (and it's
# not the in-tree FAL path).
dispatched = _dispatch_to_plugin_provider(prompt, aspect_ratio)
dispatched = _dispatch_to_plugin_provider(
prompt, aspect_ratio,
subject_reference=subject_reference,
)
if dispatched is not None:
return dispatched

Expand Down
Loading