From e1d27631091cc71a721a7d28c3a83a8771c0145e Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:11:35 +0000 Subject: [PATCH] feat(asset-gen): add MiniMax music-generation audio provider Register a MiniMax audio provider in the asset-gen provider registry so background music can be generated alongside the existing audio provider. The adapter posts the synchronous /v1/music_generation endpoint, covers the global and China regional hosts (selected via MCPFORUNITY_MINIMAX_REGION), sends the JSON request fields, requests mp3 output, and parses the base_resp.status_code / data.status / data.audio response, returning either a hosted URL or a decoded hex payload. Adds curated catalog entries for the music-generation models and unit tests covering submit, region selection, URL and hex results, and error/redaction paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/AssetGen/AssetGenModelCatalog.cs | 8 + .../AssetGen/Providers/AssetGenProviders.cs | 5 +- .../AssetGen/Providers/MiniMaxAudioAdapter.cs | 220 ++++++++++++++++++ .../Providers/MiniMaxAudioAdapter.cs.meta | 11 + .../AssetGen/AssetGenModelCatalogTests.cs | 15 ++ .../AssetGen/AssetGenProvidersTests.cs | 17 ++ .../AssetGen/MiniMaxAudioAdapterTests.cs | 183 +++++++++++++++ .../AssetGen/MiniMaxAudioAdapterTests.cs.meta | 11 + 8 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs.meta diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs index ef25af0da..0e6bcc840 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -68,6 +68,14 @@ public static class AssetGenModelCatalog new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f, DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f }, + + // Audio — minimax (music generation, MiniMaxAudioAdapter.DefaultModel is first => the + // default). The endpoint produces a full track from the prompt, so there is no duration + // knob (prompt-only, DurationField null / MaxDurationSeconds 0). + new ModelEntry { Id = MiniMaxAudioAdapter.DefaultModel, Label = "MiniMax Music 3.0", Provider = "minimax", Kind = "audio", UseCase = "Background music" }, + new ModelEntry { Id = "music-2.6", Label = "MiniMax Music 2.6", Provider = "minimax", Kind = "audio", UseCase = "Background music" }, + new ModelEntry { Id = "music-3.0-free", Label = "MiniMax Music 3.0 (free)", Provider = "minimax", Kind = "audio", UseCase = "Background music" }, + new ModelEntry { Id = "music-2.6-free", Label = "MiniMax Music 2.6 (free)", Provider = "minimax", Kind = "audio", UseCase = "Background music" }, }; /// Curated entries for a provider+kind, in curated order (default first). Never null. diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs index 026de16b3..e127bfe8c 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -6,7 +6,7 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// /// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter - /// (model: tripo/meshy; image: fal/openrouter; audio: fal; marketplace: sketchfab); unknown ids + /// (model: tripo/meshy; image: fal/openrouter; audio: fal/minimax; marketplace: sketchfab); unknown ids /// throw . advertises providers and reports /// Configured existence only — never a key value. /// @@ -44,6 +44,8 @@ public static IAudioProviderAdapter Audio(string id) { case "fal": return new FalAudioAdapter(); + case "minimax": + return new MiniMaxAudioAdapter(); default: throw new NotSupportedException($"Unknown audio provider '{id}'."); } @@ -71,6 +73,7 @@ public static IReadOnlyList List() new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } }, // fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key. new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } }, + new ProviderInfo { Id = "minimax", Kind = "audio", Configured = IsConfigured("minimax"), Capabilities = new[] { "text", "music" } }, }; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs new file mode 100644 index 000000000..3bb2382dc --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs @@ -0,0 +1,220 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// MiniMax audio provider for background-music generation via the (synchronous) + /// /v1/music_generation endpoint. The prompt drives an instrumental track; the endpoint + /// returns the result inline in a single response (no task id / poll endpoint exists), so the + /// work happens in and returns it immediately — + /// the same shape as the OpenRouter image adapter. One adapter instance handles a single job. + /// + /// The region is selected via MCPFORUNITY_MINIMAX_REGION (global — default — or + /// cn) so both the global (api.minimax.io) and China (api.minimaxi.com) + /// hosts are reachable; the key is only ever attached to the resolved region host. + /// + public sealed class MiniMaxAudioAdapter : IAudioProviderAdapter + { + internal const string GlobalEndpoint = "https://api.minimax.io/v1/music_generation"; + internal const string ChinaEndpoint = "https://api.minimaxi.com/v1/music_generation"; + private const string GlobalHost = "api.minimax.io"; + private const string ChinaHost = "api.minimaxi.com"; + internal const string RegionEnvVar = "MCPFORUNITY_MINIMAX_REGION"; + + // Default background-music model. internal so the catalog references it directly (single + // source of truth, drift-guarded). + internal const string DefaultModel = "music-3.0"; + + // We request mp3 (an import-allowed extension) and pin the result extension to it, so a + // hosted result URL without a clean extension still imports correctly. + private const string AudioFormat = "mp3"; + + public string Id => "minimax"; + + // Set once in SubmitAsync (synchronous provider), read back in PollAsync. + private byte[] _inlineData; + private string _downloadUrl; + private string _resultExt; + private string _error; + + public async Task SubmitAsync(AudioGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + ResolveRegion(out string endpoint, out string host); + // The endpoint is adapter-constructed, but validate the host before attaching the key so + // a mis-set region can never send credentials to an unexpected host. + ProviderHttp.RequireHost(endpoint, host, apiKey, "minimax submit"); + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = endpoint, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(BuildBody(model, req).ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey); + + // base_resp.status_code == 0 means the request itself succeeded; anything else is an + // application-level error (auth, quota, invalid params). + int statusCode = AsInt(json["base_resp"]?["status_code"], -1); + if (statusCode != 0) + { + string msg = json["base_resp"]?["status_msg"]?.ToString(); + _error = SecretRedactor.Scrub( + $"MiniMax music generation failed (status_code={statusCode}): {msg ?? "unknown error"}", apiKey); + return "ready"; + } + + JToken data = json["data"]; + // data.status: 1 = in_progress, 2 = completed. The endpoint is synchronous, so a + // non-completed status has no poll target to recover from — surface it as a failure. + int genStatus = AsInt(data?["status"], -1); + if (genStatus != 2) + { + _error = $"MiniMax music generation did not complete (status={genStatus})."; + return "ready"; + } + + string audio = data?["audio"]?.ToString(); + if (string.IsNullOrEmpty(audio)) + { + _error = "MiniMax completed but returned no audio."; + return "ready"; + } + + // output_format may be a hosted URL or a hex-encoded audio payload; handle both. + if (audio.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || audio.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + _downloadUrl = audio; + } + else + { + byte[] bytes = TryDecodeHex(audio); + if (bytes == null || bytes.Length == 0) + { + _error = "MiniMax returned an unrecognized audio payload."; + return "ready"; + } + _inlineData = bytes; + } + _resultExt = AudioFormat; + return "ready"; + } + + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + var result = new ProviderPollResult { Progress = 1f }; + if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl))) + { + result.State = ProviderPollState.Failed; + result.Error = _error ?? "MiniMax produced no audio."; + } + else + { + result.State = ProviderPollState.Succeeded; + result.InlineData = _inlineData; + result.DownloadUrl = _downloadUrl; + result.ResultExt = _resultExt; + } + return Task.FromResult(result); + } + + // Prompt-driven instrumental background music. Duration is not a request parameter for this + // endpoint (the model produces a full track), so req.Duration is intentionally unused. + private static JObject BuildBody(string model, AudioGenRequest req) + { + return new JObject + { + ["model"] = model, + ["prompt"] = req.Prompt ?? string.Empty, + ["is_instrumental"] = true, + ["output_format"] = "url", + ["audio_setting"] = new JObject + { + ["sample_rate"] = 44100, + ["bitrate"] = 256000, + ["format"] = AudioFormat + } + }; + } + + private static void ResolveRegion(out string endpoint, out string host) + { + string region = (Environment.GetEnvironmentVariable(RegionEnvVar) ?? string.Empty).Trim().ToLowerInvariant(); + if (region == "cn" || region == "cn_zh" || region == "china") + { + endpoint = ChinaEndpoint; + host = ChinaHost; + } + else + { + endpoint = GlobalEndpoint; + host = GlobalHost; + } + } + + private static int AsInt(JToken token, int fallback) + { + if (token == null || token.Type == JTokenType.Null) return fallback; + if (token.Type == JTokenType.Integer) return (int)token; + return int.TryParse(token.ToString(), out int v) ? v : fallback; + } + + private static byte[] TryDecodeHex(string hex) + { + if (string.IsNullOrEmpty(hex) || (hex.Length % 2) != 0) return null; + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + int hi = HexVal(hex[i * 2]); + int lo = HexVal(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return null; + bytes[i] = (byte)((hi << 4) | lo); + } + return bytes; + } + + private static int HexVal(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + private static JObject ParseOk(HttpResult res, string apiKey) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["base_resp"]?["status_msg"]?.ToString() + ?? json?["error"]?.ToString() + ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"MiniMax request failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs.meta new file mode 100644 index 000000000..812b7458b --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: edcf0554d7a94b0ab2d469529e08c2d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs index 9ef048e6c..bd448324b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs @@ -80,6 +80,21 @@ public void DefaultModelId_MatchesAdapterConstants() Assert.AreEqual(MeshyAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("meshy", "model")); Assert.AreEqual(FalAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "image")); Assert.AreEqual(FalAudioAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + Assert.AreEqual(MiniMaxAudioAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("minimax", "audio")); + } + + [Test] + public void Curated_HasMiniMaxMusicGenerationModels() + { + IReadOnlyList audio = AssetGenModelCatalog.ForProvider("minimax", "audio"); + + CollectionAssert.AreEqual( + new[] { "music-3.0", "music-2.6", "music-3.0-free", "music-2.6-free" }, + audio.Select(e => e.Id).ToList()); + + // Music generation has no duration knob (the model returns a full track). + foreach (ModelEntry e in audio) + Assert.IsNull(e.DurationField, e.Id + " should be prompt-only (no duration control)"); } [Test] diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs index 2b8e0b84f..e79585a51 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs @@ -35,6 +35,14 @@ public void Audio_Fal_ReturnsAdapter() Assert.AreEqual("fal", adapter.Id); } + [Test] + public void Audio_MiniMax_ReturnsAdapter() + { + IAudioProviderAdapter adapter = AssetGenProviders.Audio("minimax"); + Assert.IsNotNull(adapter); + Assert.AreEqual("minimax", adapter.Id); + } + [Test] public void Audio_Unimplemented_Throws() { @@ -49,6 +57,15 @@ public void List_IncludesFalAudioRow() Assert.AreEqual("audio", row.Kind); } + [Test] + public void List_IncludesMiniMaxAudioRow() + { + ProviderInfo row = FindByIdKind("minimax", "audio"); + Assert.IsNotNull(row, "List() should advertise a minimax audio row"); + Assert.AreEqual("audio", row.Kind); + CollectionAssert.Contains(row.Capabilities, "music"); + } + [Test] public void List_HasExactlyOneFalImage_AndOneFalAudio() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs new file mode 100644 index 000000000..d882cc055 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Text; +using System.Threading; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.AssetGen +{ + /// + /// Covers the MiniMax music-generation adapter: it posts the region endpoint with a Bearer key, + /// parses the synchronous response (base_resp.status_code / data.status / data.audio), returns a + /// hosted URL or a decoded hex payload, honours the region env override, and never leaks the key + /// to an unexpected host. + /// + public class MiniMaxAudioAdapterTests + { + private const string GlobalHost = "api.minimax.io"; + private const string ChinaHost = "api.minimaxi.com"; + + private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body }; + + private static HttpResult Completed(string audio) => + Json("{\"data\":{\"status\":2,\"audio\":\"" + audio + "\"},\"base_resp\":{\"status_code\":0,\"status_msg\":\"success\"}}"); + + private static AudioGenRequest Req(string model = null) => + new AudioGenRequest { Provider = "minimax", Model = model, Prompt = "calm ambient background" }; + + private static string SubmittedBody(FakeHttpTransport fake) => + Encoding.UTF8.GetString(fake.RecordedRequests[0].Body); + + [SetUp] + public void SetUp() => Environment.SetEnvironmentVariable(MiniMaxAudioAdapter.RegionEnvVar, null); + + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable(MiniMaxAudioAdapter.RegionEnvVar, null); + + [Test] + public void Submit_PostsGlobalEndpoint_WithBearerKey_JsonBody() + { + var fake = new FakeHttpTransport { Handler = _ => Completed("https://cdn.minimax.io/a.mp3") }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.IsFalse(string.IsNullOrEmpty(pid), "submit must return a non-empty provider job id"); + HttpRequestSpec sent = fake.RecordedRequests[0]; + Assert.AreEqual("POST", sent.Method); + StringAssert.Contains(GlobalHost, sent.Url); + Assert.IsTrue(sent.Headers.ContainsKey("Authorization")); + StringAssert.StartsWith("Bearer ", sent.Headers["Authorization"]); + } + + [Test] + public void Submit_DefaultModel_MatchesAdapterConstant() + { + var fake = new FakeHttpTransport { Handler = _ => Completed("https://cdn.minimax.io/a.mp3") }; + var adapter = new MiniMaxAudioAdapter(); + + adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + JObject body = JObject.Parse(SubmittedBody(fake)); + Assert.AreEqual(MiniMaxAudioAdapter.DefaultModel, (string)body["model"]); + } + + [Test] + public void Submit_ModelOverride_IsSent() + { + var fake = new FakeHttpTransport { Handler = _ => Completed("https://cdn.minimax.io/a.mp3") }; + var adapter = new MiniMaxAudioAdapter(); + + adapter.SubmitAsync(Req("music-2.6"), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + JObject body = JObject.Parse(SubmittedBody(fake)); + Assert.AreEqual("music-2.6", (string)body["model"]); + } + + [Test] + public void Submit_CnRegion_PostsChinaEndpoint() + { + Environment.SetEnvironmentVariable(MiniMaxAudioAdapter.RegionEnvVar, "cn"); + var fake = new FakeHttpTransport { Handler = _ => Completed("https://cdn.minimaxi.com/a.mp3") }; + var adapter = new MiniMaxAudioAdapter(); + + adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + StringAssert.Contains(ChinaHost, fake.RecordedRequests[0].Url); + } + + [Test] + public void Poll_UrlResult_Succeeds_WithDownloadUrl_AndMp3Ext() + { + var fake = new FakeHttpTransport { Handler = _ => Completed("https://cdn.minimax.io/track.mp3") }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + ProviderPollResult pr = adapter.PollAsync(pid, "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Succeeded, pr.State); + Assert.AreEqual("https://cdn.minimax.io/track.mp3", pr.DownloadUrl); + Assert.AreEqual("mp3", pr.ResultExt); + Assert.IsNull(pr.InlineData); + } + + [Test] + public void Poll_HexResult_DecodesToInlineData() + { + // "ID3" (mp3 header bytes) hex-encoded => 494433. + var fake = new FakeHttpTransport { Handler = _ => Completed("494433") }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + ProviderPollResult pr = adapter.PollAsync(pid, "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Succeeded, pr.State); + Assert.IsNotNull(pr.InlineData); + CollectionAssert.AreEqual(new byte[] { 0x49, 0x44, 0x33 }, pr.InlineData); + Assert.AreEqual("mp3", pr.ResultExt); + } + + [Test] + public void Submit_NonZeroStatusCode_Fails_AndRedactsKey() + { + var fake = new FakeHttpTransport + { + Handler = _ => Json("{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key mmkey123\"}}") + }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + ProviderPollResult pr = adapter.PollAsync(pid, "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Failed, pr.State); + StringAssert.DoesNotContain("mmkey123", pr.Error); + } + + [Test] + public void Submit_InProgressStatus_Fails_NoPollTarget() + { + var fake = new FakeHttpTransport + { + Handler = _ => Json("{\"data\":{\"status\":1},\"base_resp\":{\"status_code\":0}}") + }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + ProviderPollResult pr = adapter.PollAsync(pid, "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Failed, pr.State); + } + + [Test] + public void Submit_CompletedNoAudio_Fails() + { + var fake = new FakeHttpTransport + { + Handler = _ => Json("{\"data\":{\"status\":2},\"base_resp\":{\"status_code\":0}}") + }; + var adapter = new MiniMaxAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + ProviderPollResult pr = adapter.PollAsync(pid, "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Failed, pr.State); + StringAssert.Contains("audio", pr.Error); + } + + [Test] + public void Submit_HttpError_Throws_RedactsKey() + { + var fake = new FakeHttpTransport + { + Handler = _ => new HttpResult { Status = 401, IsSuccess = false, Text = "{\"base_resp\":{\"status_msg\":\"unauthorized mmkey123\"}}" } + }; + var adapter = new MiniMaxAudioAdapter(); + + Exception ex = Assert.Throws(() => + adapter.SubmitAsync(Req(), "mmkey123", fake, CancellationToken.None).GetAwaiter().GetResult()); + StringAssert.DoesNotContain("mmkey123", ex.Message); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs.meta new file mode 100644 index 000000000..706788edf --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc3f40ccdaa241979023e3ab09a9cd23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: