Skip to content

Commit 784f758

Browse files
committed
feat(asset-gen): register MiniMax as a 2D image provider
Add MiniMaxImageAdapter (IImageProviderAdapter) for the synchronous /v1/image_generation endpoint: text->image and image->image (subject_reference) with a URL or inline base64 data URI. Register minimax in the image provider chain, catalog (image-01 default, image-01-live), secure-key provider list, settings panel, CLI/server tool descriptions, and the manual-verification doc.
1 parent bd72241 commit 784f758

12 files changed

Lines changed: 409 additions & 4 deletions

File tree

MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ internal static class SecureKeyStoreConstants
99
/// <summary>Known asset-generation provider ids (lowercase).</summary>
1010
internal static readonly string[] ProviderIds =
1111
{
12-
"tripo", "meshy", "sketchfab", "fal", "openrouter"
12+
"tripo", "meshy", "sketchfab", "fal", "openrouter", "minimax", "minimax"
1313
};
1414
}
1515
}

MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ public static class AssetGenModelCatalog
5353
// Image — openrouter
5454
new ModelEntry { Id = OpenRouterAdapter.DefaultModel, Label = "Gemini 2.5 Flash Image", Provider = "openrouter", Kind = "image", UseCase = "General image" },
5555

56+
// Image — minimax (MiniMaxImageAdapter.DefaultModel is first => the default)
57+
new ModelEntry { Id = MiniMaxImageAdapter.DefaultModel, Label = "MiniMax image-01", Provider = "minimax", Kind = "image", UseCase = "General image" },
58+
new ModelEntry { Id = "image-01-live", Label = "MiniMax image-01-live", Provider = "minimax", Kind = "image", UseCase = "Live image" },
59+
5660
// 3D — tripo / meshy (defaults reference the adapter constants)
5761
new ModelEntry { Id = TripoAdapter.ModelVersion, Label = "Tripo v3.1", Provider = "tripo", Kind = "model", UseCase = "Text / image -> 3D" },
5862
new ModelEntry { Id = "P1-20260311", Label = "Tripo P1 (premium)", Provider = "tripo", Kind = "model", UseCase = "Premium 3D" },

MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public static IImageProviderAdapter Image(string id)
3333
return new FalAdapter();
3434
case "openrouter":
3535
return new OpenRouterAdapter();
36+
case "minimax":
37+
return new MiniMaxImageAdapter();
3638
default:
3739
throw new NotSupportedException($"Unknown image provider '{id}'.");
3840
}
@@ -69,6 +71,7 @@ public static IReadOnlyList<ProviderInfo> List()
6971
new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } },
7072
new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } },
7173
new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } },
74+
new ProviderInfo { Id = "minimax", Kind = "image", Configured = IsConfigured("minimax"), Capabilities = new[] { "text", "image" } },
7275
// fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key.
7376
new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } },
7477
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System;
2+
using System.Text;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using MCPForUnity.Editor.Security;
6+
using MCPForUnity.Editor.Services.AssetGen.Http;
7+
using Newtonsoft.Json;
8+
using Newtonsoft.Json.Linq;
9+
10+
namespace MCPForUnity.Editor.Services.AssetGen.Providers
11+
{
12+
/// <summary>
13+
/// MiniMax 2D image provider via the synchronous <c>/v1/image_generation</c> endpoint. Text→image
14+
/// submits a prompt; image→image attaches a <c>subject_reference</c> (a hosted URL or an inline
15+
/// base64 data URI for a local image_path). The image is returned inline (base64) or as a URL the
16+
/// job manager downloads, so all work happens in <see cref="SubmitAsync"/> and
17+
/// <see cref="PollAsync"/> returns the captured result immediately. One adapter instance handles a
18+
/// single job (the job manager captures it for submit+poll).
19+
/// </summary>
20+
public sealed class MiniMaxImageAdapter : IImageProviderAdapter
21+
{
22+
private const string Endpoint = "https://api.minimax.io/v1/image_generation";
23+
private const string Host = "api.minimax.io";
24+
// internal so the model catalog references it directly (single source of truth, drift-guarded).
25+
internal const string DefaultModel = "image-01";
26+
27+
public string Id => "minimax";
28+
29+
private byte[] _inlineData;
30+
private string _downloadUrl;
31+
private string _error;
32+
33+
public async Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
34+
{
35+
if (req == null) throw new ArgumentNullException(nameof(req));
36+
if (http == null) throw new ArgumentNullException(nameof(http));
37+
38+
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
39+
40+
var body = new JObject
41+
{
42+
["model"] = model,
43+
["prompt"] = req.Prompt ?? string.Empty
44+
};
45+
46+
// image→image: attach the reference image as a subject_reference entry. image_file accepts
47+
// a hosted URL or an inline base64 data URI (for a local image_path). Plain text→image
48+
// sends prompt only.
49+
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
50+
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
51+
if (image)
52+
{
53+
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
54+
body["subject_reference"] = new JArray(
55+
new JObject { ["type"] = "character", ["image_file"] = imageRef });
56+
}
57+
58+
// Forward explicit output dimensions for text→image only; the provider accepts a
59+
// {width,height} pair. (image→image derives size from the subject reference.)
60+
if (!image && req.Width > 0 && req.Height > 0)
61+
{
62+
body["width"] = req.Width;
63+
body["height"] = req.Height;
64+
}
65+
66+
ProviderHttp.RequireHost(Endpoint, Host, apiKey, "minimax submit");
67+
68+
var spec = new HttpRequestSpec
69+
{
70+
Method = "POST",
71+
Url = Endpoint,
72+
ContentType = "application/json",
73+
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
74+
};
75+
spec.Headers["Authorization"] = "Bearer " + apiKey;
76+
77+
HttpResult res = await http.SendAsync(spec, ct);
78+
JObject json = ParseOk(res, apiKey);
79+
80+
// Prefer a URL result (default response_format=url); fall back to inline base64.
81+
string url = ExtractImageUrl(json);
82+
if (!string.IsNullOrEmpty(url))
83+
{
84+
_downloadUrl = url;
85+
}
86+
else
87+
{
88+
string b64 = ExtractImageBase64(json);
89+
if (!string.IsNullOrEmpty(b64))
90+
{
91+
try { _inlineData = Convert.FromBase64String(b64); }
92+
catch { _error = "MiniMax returned an image payload that was not valid base64."; }
93+
}
94+
else
95+
{
96+
_error = "MiniMax returned no image. The selected model may not support image output.";
97+
}
98+
}
99+
return "ready";
100+
}
101+
102+
public Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
103+
{
104+
var result = new ProviderPollResult { Progress = 1f };
105+
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
106+
{
107+
result.State = ProviderPollState.Failed;
108+
result.Error = _error ?? "MiniMax produced no image.";
109+
}
110+
else
111+
{
112+
result.State = ProviderPollState.Succeeded;
113+
result.InlineData = _inlineData;
114+
result.DownloadUrl = _downloadUrl;
115+
}
116+
return Task.FromResult(result);
117+
}
118+
119+
private static string ExtractImageUrl(JObject json)
120+
=> (json["data"]?["image_urls"] as JArray)?[0]?.ToString();
121+
122+
private static string ExtractImageBase64(JObject json)
123+
=> (json["data"]?["image_base64"] as JArray)?[0]?.ToString();
124+
125+
private static JObject ParseOk(HttpResult res, string apiKey)
126+
{
127+
string text = ProviderHttp.BodyText(res);
128+
129+
JObject json = null;
130+
if (!string.IsNullOrEmpty(text))
131+
{
132+
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
133+
}
134+
135+
bool ok = res?.Ok == true;
136+
// MiniMax signals failure with a non-zero base_resp.status_code even on HTTP 200.
137+
int status = json?["base_resp"]?["status_code"]?.Type == JTokenType.Integer
138+
? (int)json["base_resp"]["status_code"]
139+
: 0;
140+
if (!ok || status != 0)
141+
{
142+
string detail = json?["base_resp"]?["status_msg"]?.ToString()
143+
?? json?["error"]?["message"]?.ToString()
144+
?? json?["error"]?.ToString()
145+
?? ProviderHttp.Truncate(text);
146+
throw new Exception(SecretRedactor.Scrub(
147+
$"MiniMax request failed (status={res?.Status}, base_resp.status_code={status}): {detail}", apiKey));
148+
}
149+
return json ?? new JObject();
150+
}
151+
}
152+
}

MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxImageAdapter.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ private static readonly (string Id, string Label)[] ImageProviders =
3434
{
3535
("fal", "fal"),
3636
("openrouter", "OpenRouter"),
37+
("minimax", "MiniMax"),
3738
};
3839

3940
// UI Elements

Server/src/cli/commands/asset_gen.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def import_model_file(source_path, name, output_folder, target_size, animation_t
146146

147147

148148
@asset_gen.command("generate-image")
149-
@click.option("--provider", default=None, help="Provider id (fal, openrouter).")
149+
@click.option("--provider", default=None, help="Provider id (fal, openrouter, minimax).")
150150
@click.option("--mode", default=None, help="Generation mode: text or image.")
151151
@click.option("--prompt", default=None, help="Text prompt for text->image.")
152152
@click.option("--image-path", default=None, help="Source image path for image->image.")

Server/src/services/tools/generate_image.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
@mcp_for_unity_tool(
2020
group="asset_gen",
2121
description=(
22-
"Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as "
22+
"Generate 2D images with AI providers (fal.ai, OpenRouter, MiniMax) and import them as "
2323
"textures/sprites into the Unity project. Bring-your-own-key: provider keys live "
2424
"in the editor's secure store and never cross the bridge.\n\n"
2525
"ACTIONS:\n"
@@ -42,7 +42,7 @@ async def generate_image(
4242
action: Annotated[Literal["generate", "remove_background", "status", "cancel", "list_providers"],
4343
"Action to perform."],
4444

45-
provider: Annotated[str, "Provider id (fal, openrouter)."] | None = None,
45+
provider: Annotated[str, "Provider id (fal, openrouter, minimax)."] | None = None,
4646
mode: Annotated[str, "Generation mode: text or image."] | None = None,
4747
prompt: Annotated[str, "Text prompt for text->image."] | None = None,
4848
image_path: Annotated[str, "Path to a source image for image->image mode."] | None = None,

TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public void SetUp()
2525
AssetGenJobManager.ResetForTests();
2626
Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null);
2727
Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", null);
28+
Environment.SetEnvironmentVariable("MCPFORUNITY_MINIMAX_API_KEY", null);
2829
_dir = Path.Combine(Path.GetTempPath(), "mcp_imghandler_" + Guid.NewGuid().ToString("N"));
2930
_store = new EncryptedFileKeyStore(_dir);
3031
SecureKeyStore.OverrideForTests(_store);
@@ -73,6 +74,20 @@ public void Generate_WithKey_ReturnsPendingJobId()
7374
Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"]));
7475
}
7576

77+
[Test]
78+
public void Generate_MiniMax_WithKey_ReturnsPendingJobId()
79+
{
80+
_store.Set("minimax", "mmkey");
81+
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
82+
{
83+
Handler = spec => new HttpResult { Status = 200, IsSuccess = true,
84+
Text = "{\"data\":{\"image_urls\":[\"https://cdn.minimax.io/img/x.png\"]},\"base_resp\":{\"status_code\":0}}" }
85+
};
86+
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "minimax", ["mode"] = "text", ["prompt"] = "a cat" });
87+
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
88+
Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"]));
89+
}
90+
7691
[Test]
7792
public void Generate_NoKey_ReturnsError()
7893
{
@@ -136,6 +151,7 @@ public void ListProviders_ImageOnly()
136151
string s = resp.ToString();
137152
StringAssert.Contains("fal", s);
138153
StringAssert.Contains("openrouter", s);
154+
StringAssert.Contains("minimax", s);
139155
StringAssert.DoesNotContain("tripo", s); // model providers excluded
140156
}
141157

0 commit comments

Comments
 (0)