From 12c867363ad22c671da5f0a67b67c55b5a2d23e7 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:13:58 +0800 Subject: [PATCH] feat(tools): add native MiniMax image generation --- .../services/agent_runtime/tool_execution.py | 1 + backend/app/services/agent_tools.py | 123 +++++++++++++++- .../app/services/builtin_tool_definitions.py | 81 ++++++++++ .../test_agent_tools_deploy_contracts.py | 1 + ...est_agent_tools_typed_image_outcomes_v2.py | 138 +++++++++++++++++- 5 files changed, 342 insertions(+), 2 deletions(-) diff --git a/backend/app/services/agent_runtime/tool_execution.py b/backend/app/services/agent_runtime/tool_execution.py index 46310aa19..279a906a1 100644 --- a/backend/app/services/agent_runtime/tool_execution.py +++ b/backend/app/services/agent_runtime/tool_execution.py @@ -45,6 +45,7 @@ "generate_image_siliconflow", "generate_image_openai", "generate_image_google", + "generate_image_minimax", "generate_image_custom", } ) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 0224cdf65..65550b957 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -448,6 +448,7 @@ async def _get_scoped_agentbay_client( "generate_image_siliconflow", "generate_image_openai", "generate_image_google", + "generate_image_minimax", "generate_image_custom", } ) @@ -456,6 +457,7 @@ async def _get_scoped_agentbay_client( "generate_image_siliconflow": "siliconflow", "generate_image_openai": "openai", "generate_image_google": "google", + "generate_image_minimax": "minimax", "generate_image_custom": "custom", } @@ -1246,6 +1248,7 @@ async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: "generate_image_siliconflow", "generate_image_openai", "generate_image_google", + "generate_image_minimax", } and str(config.get("api_key") or "").strip(): ready.append(tool) elif name == "generate_image_custom" and all( @@ -3272,6 +3275,13 @@ async def execute_tool( lambda temp_ws: _generate_image(agent_id, temp_ws, arguments, "google"), sync_back=True, ) + elif tool_name == "generate_image_minimax": + result = await _run_with_temp_workspace( + agent_id, + _agent_tenant_id, + lambda temp_ws: _generate_image(agent_id, temp_ws, arguments, "minimax"), + sync_back=True, + ) elif tool_name == "generate_image_custom": result = await _run_with_temp_workspace( agent_id, @@ -11351,6 +11361,23 @@ async def _generate_image_outcome( "invalid_tool_arguments", ) + minimax_seed = arguments.get("seed") + minimax_prompt_optimizer = arguments.get("prompt_optimizer", False) + if provider == "minimax" and ( + ( + minimax_seed is not None + and ( + not isinstance(minimax_seed, int) + or isinstance(minimax_seed, bool) + ) + ) + or not isinstance(minimax_prompt_optimizer, bool) + ): + return _typed_failure( + "MiniMax seed must be an integer and prompt_optimizer must be a boolean.", + "invalid_tool_arguments", + ) + save_path_value = arguments.get("save_path", "") if save_path_value is not None and not isinstance(save_path_value, str): return _typed_failure( @@ -11375,7 +11402,7 @@ async def _generate_image_outcome( "workspace_path_invalid", ) - if provider not in {"siliconflow", "openai", "google", "custom"}: + if provider not in {"siliconflow", "openai", "google", "minimax", "custom"}: return _typed_failure( "Unknown image generation provider.", "invalid_tool_arguments", @@ -11423,6 +11450,16 @@ async def _generate_image_outcome( prompt, size, ) + elif provider == "minimax": + image_bytes = await _generate_image_minimax( + api_key, + model or "image-01", + base_url or "https://api.minimax.io/v1/image_generation", + prompt, + size, + seed=minimax_seed, + prompt_optimizer=minimax_prompt_optimizer, + ) else: image_bytes = await _generate_image_custom_api( api_key=api_key, @@ -11678,6 +11715,90 @@ async def _generate_image_openai( ) +_MINIMAX_ASPECT_RATIO_BY_SIZE = { + "1024x1024": "1:1", + "1024x768": "4:3", + "768x1024": "3:4", + "1366x768": "16:9", + "768x1366": "9:16", + "1536x1024": "3:2", + "1024x1536": "2:3", +} + + +async def _generate_image_minimax( + api_key: str, + model: str, + endpoint: str, + prompt: str, + size: str, + *, + seed: object = None, + prompt_optimizer: object = False, +) -> bytes: + """Generate one image with the native MiniMax image API.""" + import httpx + + payload: dict[str, object] = { + "model": model, + "prompt": prompt, + "aspect_ratio": _MINIMAX_ASPECT_RATIO_BY_SIZE[size], + "response_format": "base64", + "n": 1, + "prompt_optimizer": prompt_optimizer is True, + } + if isinstance(seed, int) and not isinstance(seed, bool): + payload["seed"] = seed + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(endpoint, json=payload, headers=headers) + _settle_image_provider_status("MiniMax", resp.status_code) + try: + data = resp.json() + except Exception: + _image_generation_unknown( + "image_provider_response_invalid", + "MiniMax returned an unreadable success response.", + ) + if not isinstance(data, Mapping): + _image_generation_unknown( + "image_provider_response_invalid", + "MiniMax returned an invalid success response.", + ) + + base_resp = data.get("base_resp") + if not isinstance(base_resp, Mapping): + _image_generation_unknown( + "image_provider_response_invalid", + "MiniMax success response omitted its status receipt.", + ) + if base_resp.get("status_code") != 0: + _image_generation_failure( + "image_provider_rejected", + "MiniMax rejected the image generation request.", + ) + result = data.get("data") + if not isinstance(result, Mapping): + _image_generation_unknown( + "image_provider_response_invalid", + "MiniMax success response omitted the image receipt.", + ) + encoded_images = result.get("image_base64") + if isinstance(encoded_images, list) and encoded_images: + return _decode_generated_image_base64(encoded_images[0]) + image_urls = result.get("image_urls") + if isinstance(image_urls, list) and image_urls: + return await _download_generated_image(image_urls[0], client) + _image_generation_unknown( + "image_provider_response_invalid", + "MiniMax success response omitted the image receipt.", + ) + + def _json_path_get(data: Any, path: str) -> Any: """Read a simple dotted JSON path, with numeric list indexes.""" if not path: diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 83f6b0054..60afbad75 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -1289,6 +1289,86 @@ ] }, }, + { + "name": "generate_image_minimax", + "display_name": "Generate Image (MiniMax)", + "description": "Generate an image with the native MiniMax image API.", + "category": "media", + "icon": "🎨", + "is_default": False, + "parameters_schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 1500, + "description": "Detailed image description.", + }, + "size": { + "type": "string", + "enum": ["1024x1024", "1024x768", "768x1024", "1366x768", "768x1366", "1536x1024", "1024x1536"], + "description": "Image size. Default 1024x1024.", + }, + "seed": { + "type": "integer", + "description": "Optional random seed for reproducible generation.", + }, + "prompt_optimizer": { + "type": "boolean", + "description": "Enable automatic prompt optimization. Default false.", + }, + "save_path": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+\\.(?:png|jpg|jpeg|webp)$", + "description": "Workspace-relative image path. Default: auto.", + }, + }, + "required": ["prompt"], + }, + "config": { + "model": "", + "api_key": "", + "base_url": "", + }, + "config_schema": { + "fields": [ + { + "key": "model", + "label": "Model", + "type": "select", + "default": "image-01", + "options": [ + {"value": "image-01", "label": "image-01"}, + {"value": "image-01-live", "label": "image-01-live"}, + ], + }, + { + "key": "api_key", + "label": "API Key", + "type": "password", + "default": "", + "placeholder": "MiniMax API Key", + }, + { + "key": "base_url", + "label": "Regional Endpoint", + "type": "select", + "default": "https://api.minimax.io/v1/image_generation", + "options": [ + { + "value": "https://api.minimax.io/v1/image_generation", + "label": "Global", + }, + { + "value": "https://api.minimaxi.com/v1/image_generation", + "label": "China", + }, + ], + }, + ] + }, + }, { "name": "generate_image_custom", "display_name": "Generate Image (Custom API)", @@ -3810,6 +3890,7 @@ "generate_image_siliconflow": 120, "generate_image_openai": 120, "generate_image_google": 120, + "generate_image_minimax": 120, "generate_image_custom": 120, } diff --git a/backend/tests/test_agent_tools_deploy_contracts.py b/backend/tests/test_agent_tools_deploy_contracts.py index 4f210cfbc..ee4a215f8 100644 --- a/backend/tests/test_agent_tools_deploy_contracts.py +++ b/backend/tests/test_agent_tools_deploy_contracts.py @@ -33,6 +33,7 @@ "generate_image_siliconflow", "generate_image_openai", "generate_image_google", + "generate_image_minimax", "generate_image_custom", ) class FakeResponse: diff --git a/backend/tests/test_agent_tools_typed_image_outcomes_v2.py b/backend/tests/test_agent_tools_typed_image_outcomes_v2.py index 1e36fd354..11c254e41 100644 --- a/backend/tests/test_agent_tools_typed_image_outcomes_v2.py +++ b/backend/tests/test_agent_tools_typed_image_outcomes_v2.py @@ -11,13 +11,17 @@ from app.services import activity_logger, agent_tools from app.services.agent_runtime.tool_execution import ToolExecutionOutcome -from app.services.builtin_tool_definitions import builtin_model_definition +from app.services.builtin_tool_definitions import ( + BUILTIN_TOOL_DEFINITIONS, + builtin_model_definition, +) IMAGE_GENERATION_TOOLS = ( "generate_image_siliconflow", "generate_image_openai", "generate_image_google", + "generate_image_minimax", "generate_image_custom", ) @@ -114,6 +118,17 @@ def _provider_payload( } ] } + if tool_name == "generate_image_minimax": + data = ( + {"image_urls": ["https://images.example.test/generated.png"]} + if use_download_url + else {"image_base64": [encoded]} + ) + return { + "data": data, + "metadata": {"success_count": 1, "failed_count": 0}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + } image_ref = ( "https://images.example.test/generated.png" if use_download_url @@ -273,6 +288,27 @@ def test_image_contracts_validate_sources_prompt_size_and_save_path() -> None: assert schema["properties"]["save_path"]["pattern"] +def test_minimax_definition_exposes_target_models_and_regional_endpoints() -> None: + definition = next( + item + for item in BUILTIN_TOOL_DEFINITIONS + if item["name"] == "generate_image_minimax" + ) + fields = { + field["key"]: field + for field in definition["config_schema"]["fields"] + } + + assert [option["value"] for option in fields["model"]["options"]] == [ + "image-01", + "image-01-live", + ] + assert [option["value"] for option in fields["base_url"]["options"]] == [ + "https://api.minimax.io/v1/image_generation", + "https://api.minimaxi.com/v1/image_generation", + ] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("status_code", "payload", "expected_status"), @@ -526,6 +562,7 @@ async def test_generate_provider_response_has_a_typed_settlement_boundary( ( "generate_image_siliconflow", "generate_image_openai", + "generate_image_minimax", "generate_image_custom", ), ) @@ -655,6 +692,105 @@ async def test_sync_failure_after_generation_is_unknown_without_regeneration( assert calls["flush"] == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoint", + ( + "https://api.minimax.io/v1/image_generation", + "https://api.minimaxi.com/v1/image_generation", + ), +) +async def test_minimax_uses_regional_native_endpoint_and_request_schema( + monkeypatch, + endpoint: str, +) -> None: + request: dict = {} + + class Client: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, url, *, json, headers): + request.update(url=url, json=json, headers=headers) + return FakeResponse( + 200, + { + "data": {"image_base64": [PNG_B64]}, + "metadata": {"success_count": 1, "failed_count": 0}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + }, + ) + + monkeypatch.setattr(httpx, "AsyncClient", Client) + + image = await agent_tools._generate_image_minimax( + "image-secret", + "image-01-live", + endpoint, + "a quiet mountain", + "1366x768", + seed=42, + prompt_optimizer=True, + ) + + assert image == PNG_BYTES + assert request["url"] == endpoint + assert request["headers"]["Authorization"] == "Bearer image-secret" + assert request["json"] == { + "model": "image-01-live", + "prompt": "a quiet mountain", + "aspect_ratio": "16:9", + "response_format": "base64", + "n": 1, + "prompt_optimizer": True, + "seed": 42, + } + + +@pytest.mark.asyncio +async def test_minimax_explicit_provider_rejection_is_failed(monkeypatch) -> None: + class Client: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, *args, **kwargs): + return FakeResponse( + 200, + { + "base_resp": { + "status_code": 2013, + "status_msg": "invalid input", + } + }, + ) + + monkeypatch.setattr(httpx, "AsyncClient", Client) + + with pytest.raises(agent_tools._ImageGenerationBoundaryError) as exc_info: + await agent_tools._generate_image_minimax( + "image-secret", + "image-01", + "https://api.minimax.io/v1/image_generation", + "a quiet mountain", + "1024x1024", + ) + + assert exc_info.value.status == "failed" + assert exc_info.value.error_code == "image_provider_rejected" + + @pytest.mark.asyncio async def test_generate_rejects_string_prefix_sibling_escape_before_dispatch( monkeypatch,