Skip to content

Commit f34068e

Browse files
committed
feat(asset-gen): register MiniMax image provider in the image tool chain
Add MiniMaxImageAdapter (synchronous /v1/image_generation endpoint, Bearer auth) for text->image and image->image (subject_reference) generation, and register the minimax provider across the adapter factory, model catalog, secure-store id list, GUI panel, and Python tool/CLI surface.
1 parent bd72241 commit f34068e

13 files changed

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

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/AssetGenModelCatalogTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public void DefaultModelId_PerKindPerProvider()
6767
Assert.AreEqual("fal-ai/stable-audio-25/text-to-audio", AssetGenModelCatalog.DefaultModelId("fal", "audio"));
6868
Assert.AreEqual("v3.1-20260211", AssetGenModelCatalog.DefaultModelId("tripo", "model"));
6969
Assert.AreEqual("meshy-6", AssetGenModelCatalog.DefaultModelId("meshy", "model"));
70+
Assert.AreEqual("image-01", AssetGenModelCatalog.DefaultModelId("minimax", "image"));
7071
Assert.IsNull(AssetGenModelCatalog.DefaultModelId("nope", "audio"), "unknown provider => null, not a throw");
7172
}
7273

@@ -80,6 +81,7 @@ public void DefaultModelId_MatchesAdapterConstants()
8081
Assert.AreEqual(MeshyAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("meshy", "model"));
8182
Assert.AreEqual(FalAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "image"));
8283
Assert.AreEqual(FalAudioAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "audio"));
84+
Assert.AreEqual(MiniMaxImageAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("minimax", "image"));
8385
}
8486

8587
[Test]
@@ -90,5 +92,16 @@ public void Find_ReturnsEntry_ByExactId()
9092
Assert.AreEqual("audio", e.Kind);
9193
Assert.IsNull(AssetGenModelCatalog.Find("does/not/exist"));
9294
}
95+
96+
[Test]
97+
public void Curated_HasMiniMaxImageModels()
98+
{
99+
IReadOnlyList<ModelEntry> image = AssetGenModelCatalog.ForProvider("minimax", "image");
100+
101+
CollectionAssert.AreEqual(
102+
new[] { "image-01", "image-01-live" },
103+
image.Select(e => e.Id).ToList());
104+
Assert.AreEqual("image", image[0].Kind);
105+
}
93106
}
94107
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ public void Audio_Fal_ReturnsAdapter()
3535
Assert.AreEqual("fal", adapter.Id);
3636
}
3737

38+
[Test]
39+
public void Image_MiniMax_ReturnsAdapter()
40+
{
41+
IImageProviderAdapter adapter = AssetGenProviders.Image("minimax");
42+
Assert.IsNotNull(adapter);
43+
Assert.AreEqual("minimax", adapter.Id);
44+
}
45+
3846
[Test]
3947
public void Audio_Unimplemented_Throws()
4048
{

0 commit comments

Comments
 (0)