From 837f2d131584a0c1e49ac4f7256df2df29cbe52e Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:07:16 -0400 Subject: [PATCH 1/7] feat: discover and validate current fal asset models --- .../Services/AssetGen/AssetGenJobManager.cs | 17 +- .../Services/AssetGen/AssetGenModelCatalog.cs | 70 ++- .../Services/AssetGen/FalModelCatalog.cs | 347 +++++++++++++ .../Services/AssetGen/FalModelCatalog.cs.meta | 11 + .../Services/AssetGen/FalModelSchema.cs | 187 +++++++ .../Services/AssetGen/FalModelSchema.cs.meta | 11 + .../Services/AssetGen/Http/HttpResult.cs | 1 + .../AssetGen/Http/UnityWebRequestTransport.cs | 3 +- .../Services/AssetGen/Providers/FalAdapter.cs | 17 +- .../AssetGen/Providers/FalAudioAdapter.cs | 18 +- .../AssetGen/Providers/ProviderModels.cs | 2 + .../Tools/AssetGen/AssetGenToolHelpers.cs | 38 ++ .../Editor/Tools/AssetGen/GenerateAudio.cs | 9 +- .../Editor/Tools/AssetGen/GenerateImage.cs | 4 +- .../Editor/Tools/AssetGen/GenerateModel.cs | 3 +- .../Components/AssetGen/McpAssetGenSection.cs | 106 ++-- Server/src/cli/commands/asset_gen.py | 21 + Server/src/services/tools/generate_audio.py | 14 +- Server/src/services/tools/generate_image.py | 10 +- Server/src/services/tools/generate_model.py | 6 +- Server/tests/test_asset_gen_catalog.py | 63 +++ .../AssetGen/AssetGenJobManagerTests.cs | 2 + .../AssetGen/AssetGenModelCatalogTests.cs | 5 + .../EditMode/AssetGen/FalModelCatalogTests.cs | 460 ++++++++++++++++++ .../AssetGen/FalModelCatalogTests.cs.meta | 11 + .../EditMode/AssetGen/GenerateAudioTests.cs | 2 + .../EditMode/AssetGen/GenerateImageTests.cs | 2 + docs/asset-gen-manual-verification.md | 12 + .../tools/asset_gen/generate_audio.md | 20 +- .../tools/asset_gen/generate_image.md | 17 +- .../tools/asset_gen/generate_model.md | 12 +- 31 files changed, 1399 insertions(+), 102 deletions(-) create mode 100644 MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta create mode 100644 Server/tests/test_asset_gen_catalog.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs.meta diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index e9fa9e674..cfccf8b5a 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -56,6 +56,7 @@ public static class AssetGenJobManager internal static Func ImportOverrideForTests; internal static double PollIntervalSeconds = 3.0; internal static double TimeoutSeconds = 600.0; + internal static bool SkipModelVerificationForTests; private static readonly Dictionary Jobs = new(); private static readonly Dictionary Runners = new(); @@ -132,7 +133,12 @@ public static AssetGenJob StartImageGeneration(ImageGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (!SkipModelVerificationForTests && string.Equals(provider, "fal", StringComparison.OrdinalIgnoreCase)) + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(string.IsNullOrEmpty(req.Model) ? FalAdapter.DefaultModel : req.Model, "image", req.Mode, ct, apiKey); + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)), Transport = transport, @@ -160,7 +166,12 @@ public static AssetGenJob StartAudioGeneration(AudioGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (!SkipModelVerificationForTests) + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(string.IsNullOrEmpty(req.Model) ? FalAudioAdapter.DefaultModel : req.Model, "audio", "text", ct, apiKey); + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? AudioImportPipeline.ImportInto, Transport = transport, @@ -597,6 +608,8 @@ internal static void ResetForTests() ImportOverrideForTests = null; PollIntervalSeconds = 3.0; TimeoutSeconds = 600.0; + SkipModelVerificationForTests = false; + AssetGenModelCatalog.ResetForTests(); } } } diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs index ef25af0da..c2616c3a6 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -31,15 +31,27 @@ public sealed class ModelEntry public float MinDurationSeconds; public bool Loopable; public string CommercialNote; // non-null => show a license caveat under the dropdown - public bool FromRefresh; // true => merged from a fal-catalog refresh (Phase 5) + public bool FromRefresh; + public string PromptField = "prompt"; + public bool DurationIsInteger = true; + public float DurationScale = 1f; // request units per second (e.g. milliseconds) + public string EditModelId; + public string ImageInputField = "image_urls"; + public bool ImageInputIsArray = true; + public bool SupportsNumImages = true; + public bool EditSupportsNumImages = true; + public bool SupportsImageSize = true; + public string LicenseType; + public string ModelUrl; + public string VerifiedAt; + public string OutputFormat; + public string EditOutputFormat; } /// - /// Curated, always-present registry of selectable models per provider+kind, with metadata for - /// the Asset Generation panel. The first curated entry per (provider, kind) is the default, and - /// each default's references the owning adapter's constant directly - /// — so the panel's shown default always equals what an omitted model param resolves to - /// (a drift-guard test pins the two). A fal-catalog refresh overlay is layered on in Phase 5. + /// Shared model registry for the panel and tools. A successfully refreshed fal snapshot + /// replaces its bundled entries, including removals. Bundled entries bootstrap first use; + /// other providers remain bundled until their discovery adapters are implemented. /// public static class AssetGenModelCatalog { @@ -60,46 +72,48 @@ public static class AssetGenModelCatalog // Audio — fal (order: stable-audio, cassette SFX, cassette music, lyria). DurationField // is the request key each endpoint expects; null (Lyria) => prompt-only, no duration knob. - new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f, + new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", MaxDurationSeconds = 190f, DurationField = "seconds_total", DefaultDurationSeconds = 30f, - CommercialNote = "Free under $1M annual revenue (Stability Community License); an Enterprise license is required at or above $1M." }, - new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", PriceLabel = "$0.01/gen", MaxDurationSeconds = 30f, + CommercialNote = "Review the model's license and provider terms before commercial use." }, + new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", MaxDurationSeconds = 30f, DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, - new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f, + new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", 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 }, + new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", MaxDurationSeconds = 30f }, }; - /// Curated entries for a provider+kind, in curated order (default first). Never null. + internal static IReadOnlyList Bundled(string provider, string kind) + => Curated.Where(e => Eq(e.Provider, provider) && Eq(e.Kind, kind)).ToArray(); + + /// Current entries for a provider+kind. Never null. public static IReadOnlyList ForProvider(string provider, string kind) { - var result = new List(); - foreach (ModelEntry e in Curated) - if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) result.Add(e); - return result; + if (Eq(provider, "fal") && FalModelCatalog.TryGet(kind, out var entries)) return entries; + return Bundled(provider, kind); } - /// The curated entry with this exact id, or null. + /// The current entry with this id, or null. public static ModelEntry Find(string id) { if (string.IsNullOrEmpty(id)) return null; - foreach (ModelEntry e in Curated) - if (Eq(e.Id, id)) return e; + foreach (string kind in new[] { "audio", "image", "model" }) + foreach (string provider in new[] { "fal", "openrouter", "tripo", "meshy" }) + foreach (ModelEntry e in ForProvider(provider, kind)) + if (Eq(e.Id, id)) return e; return null; } - /// The default model id for a provider+kind (the first curated entry), or null. + /// The first current entry for a provider+kind, or null. public static string DefaultModelId(string provider, string kind) { - foreach (ModelEntry e in Curated) - if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) return e.Id; - return null; + return ForProvider(provider, kind).FirstOrDefault()?.Id; } /// /// The model id a generate_* tool should use: an explicit wins, - /// else the GUI-selected model for this (kind, provider), else the curated default. Null when - /// nothing resolves (the adapter then falls back to its own constant). Single home for the + /// else the GUI-selected model for this (kind, provider), else the catalog default. Missing + /// saved selections in a refreshed fal catalog are rejected; explicit IDs are verified at submit. + /// Single home for the /// empty -> GUI-selected -> catalog-default precedence shared by all three generate tools. /// public static string ResolveModel(string kind, string provider, string requested) @@ -107,11 +121,13 @@ public static string ResolveModel(string kind, string provider, string requested string model = requested; if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel(kind, provider); if (string.IsNullOrWhiteSpace(model)) model = DefaultModelId(provider, kind); + if (Eq(provider, "fal") && string.IsNullOrWhiteSpace(requested) && FalModelCatalog.TryGet(kind, out var entries) + && (string.IsNullOrWhiteSpace(model) || !entries.Any(e => Eq(e.Id, model)))) + throw new InvalidOperationException($"Model '{model}' is not in the verified {kind} catalog. Refresh models and choose an available model; your saved selection has been preserved."); return string.IsNullOrWhiteSpace(model) ? null : model; } - /// Clears any test/refresh state. The refresh overlay is added in Phase 5; no-op today. - internal static void ResetForTests() { } + internal static void ResetForTests(bool isolate = false) => FalModelCatalog.ResetForTests(isolate); private static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs new file mode 100644 index 000000000..058239f83 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + /// + /// Public fal discovery, optionally using a configured key for higher rate limits. Each kind + /// is committed only after all metadata pages and shortlisted schemas succeed. + /// Failures preserve the last successful snapshot. + /// Invoke on the editor thread; UnityWebRequest and its continuations stay on that thread. + /// + public static class FalModelCatalog + { + private const string ApiUrl = "https://api.fal.ai/v1/models"; + private const int CandidateLimit = 5; + private static readonly TimeSpan Lifetime = TimeSpan.FromHours(24); + private static readonly Dictionary Snapshots = new(); + private static readonly Dictionary> Refreshes = new(); + private static readonly Dictionary Errors = new(); + private static readonly Dictionary Attempts = new(); + private static bool loaded; + private static readonly SemaphoreSlim RequestGate = new(1, 1); + private static DateTime nextRequestAt; + public static event Action Changed; + + internal static IHttpTransport TransportOverrideForTests; + internal static string CachePathOverrideForTests; + internal static Func UtcNow = () => DateTime.UtcNow; + internal static Func DelayOverrideForTests; + + private sealed class Snapshot + { + public string Kind; + public DateTime CheckedAt; + public List Entries; + [JsonIgnore] public bool FromDisk; + } + + private sealed class Cache + { + public int Version = 1; + public List Snapshots; + } + + public static bool IsRefreshing(string kind) => Refreshes.TryGetValue(kind, out var task) && !task.IsCompleted; + public static string LastError(string kind) => Errors.TryGetValue(kind, out var error) ? error : null; + + public static string Source(string kind) + { + Load(); + return Snapshots.TryGetValue(kind, out var snapshot) ? snapshot.FromDisk ? "cache" : "live" : "bundled"; + } + + public static string VerifiedAt(string kind) + { + Load(); + return Snapshots.TryGetValue(kind, out var snapshot) ? snapshot.CheckedAt.ToString("O") : null; + } + + public static bool IsStale(string kind) + { + Load(); + return !Snapshots.TryGetValue(kind, out var snapshot) || UtcNow() - snapshot.CheckedAt >= Lifetime; + } + + internal static bool TryGet(string kind, out IReadOnlyList entries) + { + Load(); + if (Snapshots.TryGetValue(kind, out var snapshot)) + { + entries = snapshot.Entries; + return true; + } + entries = null; + return false; + } + + /// Coalesces concurrent refreshes. Automatic retries back off for two minutes. + public static Task RefreshAsync(string kind, bool force = false) + { + if (kind != "audio" && kind != "image") throw new ArgumentException("fal discovery supports audio and image.", nameof(kind)); + Load(); + if (IsRefreshing(kind)) return Refreshes[kind]; + if (!force && !IsStale(kind)) return Task.FromResult(true); + if (!force && Attempts.TryGetValue(kind, out var attempted) && UtcNow() - attempted < TimeSpan.FromMinutes(2)) + return Task.FromResult(false); + Attempts[kind] = UtcNow(); + var task = RefreshCore(kind); + Refreshes[kind] = task; + return task; + } + + private static async Task RefreshCore(string kind) + { + string apiKey = null; + try + { + // Public discovery works without credentials; a configured key grants higher limits. + if (TransportOverrideForTests == null) + try { SecureKeyStore.Current.TryGet("fal", out apiKey); } catch { /* public fallback */ } + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var http = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var all = await ListMetadata(kind, http, timeout.Token, apiKey); + var bundled = AssetGenModelCatalog.Bundled("fal", kind); + var preferred = bundled.Select(e => e.Id).ToList(); + string selected = AssetGenPrefs.GetSelectedModel(kind, "fal"); + if (FalModelSchema.SafeId(selected)) preferred.Insert(0, selected); + // Known profiles and user selections first; vendor highlights and recency discover + // new candidates. Recency is not presented as a measured quality score. + var candidates = all.Values + .Where(model => FalModelSchema.IsCandidate(model, kind)) + .OrderBy(model => preferred.Contains((string)model["endpoint_id"]) ? preferred.IndexOf((string)model["endpoint_id"]) : int.MaxValue) + .ThenByDescending(model => (bool?)model["metadata"]?["highlighted"] == true || (bool?)model["metadata"]?["pinned"] == true) + .ThenByDescending(model => (string)model["metadata"]?["updated_at"] ?? (string)model["metadata"]?["date"]) + .ThenBy(model => (string)model["endpoint_id"], StringComparer.Ordinal) + .Take(CandidateLimit + preferred.Count).ToArray(); + var ids = candidates.Select(model => (string)model["endpoint_id"]).ToList(); + if (kind == "image") ids.AddRange(ids.Select(id => id + "/edit").ToArray()); + var details = await Details(ids, http, timeout.Token, apiKey); + string checkedAt = UtcNow().ToString("O"); + var entries = new List(); + foreach (var candidate in candidates) + { + string id = (string)candidate["endpoint_id"]; + if (!details.TryGetValue(id, out var model)) continue; // removed between list and find + var entry = FalModelSchema.Parse(model, kind, checkedAt); + if (entry == null) continue; + if (kind == "image" && details.TryGetValue(id + "/edit", out var editModel)) + { + var edit = FalModelSchema.Parse(editModel, "image", checkedAt, edit: true); + if (edit != null) + { + entry.EditModelId = edit.Id; + entry.ImageInputField = edit.ImageInputField; + entry.ImageInputIsArray = edit.ImageInputIsArray; + entry.EditSupportsNumImages = edit.SupportsNumImages; + entry.EditOutputFormat = edit.OutputFormat; + } + } + entries.Add(entry); + } + // A selected non-default model must not silently become the automatic default. + var order = bundled.Select((entry, index) => new { entry.Id, Index = index }).ToDictionary(entry => entry.Id, entry => entry.Index); + var ordered = entries.OrderBy(entry => order.TryGetValue(entry.Id, out int index) ? index : int.MaxValue).ToList(); + Snapshots[kind] = new Snapshot { Kind = kind, CheckedAt = UtcNow(), Entries = ordered }; + Errors.Remove(kind); + Save(); + Changed?.Invoke(kind); + return true; + } + catch (Exception error) + { + Errors[kind] = error is OperationCanceledException ? "Model refresh timed out. Previous catalog retained." : SecretRedactor.Scrub(error.Message, apiKey); + return false; + } + } + + private static async Task> ListMetadata(string kind, IHttpTransport http, CancellationToken ct, string apiKey = null) + { + var models = new Dictionary(StringComparer.Ordinal); + var cursors = new HashSet(); + string cursor = null; + for (int page = 0; page < 50; page++) + { + string url = ApiUrl + "?category=" + (kind == "audio" ? "text-to-audio" : "text-to-image") + "&status=active&limit=100"; + if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); + var json = await Get(url, http, ct, apiKey); + foreach (JObject model in (JArray)json["models"]) + { + string id = (string)model["endpoint_id"]; + if (FalModelSchema.SafeId(id) && (string)model["metadata"]?["status"] == "active") models[id] = model; + } + if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) return models; + cursor = (string)json["next_cursor"]; + if (string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) throw new InvalidOperationException("Incomplete model pagination. Previous catalog retained."); + } + throw new InvalidOperationException("Model pagination exceeded its limit. Previous catalog retained."); + } + + private static async Task> Details(IEnumerable ids, IHttpTransport http, CancellationToken ct, string apiKey = null) + { + var result = new Dictionary(StringComparer.Ordinal); + string[] wanted = ids.Distinct(StringComparer.Ordinal).Where(FalModelSchema.SafeId).ToArray(); + // Expanded schemas are large; small batches respect the provider's expansion limits. + for (int offset = 0; offset < wanted.Length; offset += 5) + { + string query = string.Join("&", wanted.Skip(offset).Take(5).Select(id => "endpoint_id=" + Uri.EscapeDataString(id))); + string cursor = null; + var cursors = new HashSet(); + for (int page = 0; page < 10; page++) + { + string url = ApiUrl + "?" + query + "&expand=openapi-3.0&limit=5"; + if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); + var json = await Get(url, http, ct, apiKey, allowMissing: true); + foreach (JObject model in (JArray)json["models"]) + { + string id = (string)model["endpoint_id"]; + if (!wanted.Contains(id, StringComparer.Ordinal)) throw new InvalidOperationException("Unexpected model in schema response. Previous catalog retained."); + if ((string)model["metadata"]?["status"] == "active" && (!(model["openapi"] is JObject api) || api["error"] != null)) + throw new InvalidOperationException("Model schema expansion failed. Previous catalog retained."); + result[id] = model; + } + if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) break; + cursor = (string)json["next_cursor"]; + if (page == 9 || string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) + throw new InvalidOperationException("Incomplete model schema pagination. Previous catalog retained."); + } + } + return result; + } + + private static async Task Get(string url, IHttpTransport http, CancellationToken ct, string apiKey = null, bool allowMissing = false) + { + ProviderHttp.RequireHost(url, "api.fal.ai", apiKey, "fal catalog"); + HttpResult response = null; + for (int attempt = 0; attempt < 3; attempt++) + { + await RequestGate.WaitAsync(ct); + try + { + TimeSpan wait = nextRequestAt - UtcNow(); + if (wait > TimeSpan.Zero) await Delay(wait, ct); + ct.ThrowIfCancellationRequested(); + var request = new HttpRequestSpec { Method = "GET", Url = url }; + request.Headers["User-Agent"] = "MCPForUnity/ModelCatalog"; + if (!string.IsNullOrEmpty(apiKey)) request.Headers["Authorization"] = "Key " + apiKey; + response = await http.SendAsync(request, ct); + // Public discovery has a much smaller allowance than authenticated calls. + // Keep background refreshes from exhausting it before a preflight query. + nextRequestAt = UtcNow().AddSeconds(string.IsNullOrEmpty(apiKey) ? 7 : 1); + } + finally { RequestGate.Release(); } + if (response?.Status != 429 || attempt == 2) break; + await Delay(TimeSpan.FromSeconds(Math.Min(10, Math.Max(1, response.RetryAfterSeconds ?? (2 << attempt)))), ct); + } + // Find mode returns 404 when every requested endpoint is absent. In particular, + // missing edit endpoints are an expected negative discovery result, not an outage. + if (allowMissing && response?.Status == 404) + { + var missing = JObject.Parse(ProviderHttp.BodyText(response)); + if ((string)missing["error"]?["type"] == "not_found") + return new JObject { ["models"] = new JArray(), ["has_more"] = false }; + } + // Do not persist provider error bodies or credentials. + if (response == null || response.Status < 200 || response.Status >= 300) + throw new InvalidOperationException($"Model catalog request failed (HTTP {response?.Status}). Previous catalog retained."); + var json = JObject.Parse(ProviderHttp.BodyText(response)); + if (!(json["models"] is JArray)) throw new InvalidOperationException("Invalid model catalog response. Previous catalog retained."); + return json; + } + + private static Task Delay(TimeSpan duration, CancellationToken ct) + => DelayOverrideForTests?.Invoke(duration, ct) ?? Task.Delay(duration, ct); + + /// Recheck the exact endpoint before a paid submit, and capture its live profile. + internal static async Task VerifyForGeneration(string id, string kind, string mode, CancellationToken ct, string apiKey = null) + { + if (!FalModelSchema.SafeId(id)) throw new InvalidOperationException("Invalid fal model ID."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var ids = kind == "image" && mode == "image" ? new[] { id, id + "/edit" } : new[] { id }; + var models = await Details(ids, TransportOverrideForTests ?? new UnityWebRequestTransport(), timeout.Token, apiKey); + if (!models.TryGetValue(id, out var model)) throw new InvalidOperationException($"Model '{id}' is unavailable. Refresh models and choose another model."); + var entry = FalModelSchema.Parse(model, kind, UtcNow().ToString("O")); + if (entry == null) throw new InvalidOperationException($"Model '{id}' is unavailable or incompatible with this tool. Refresh models and choose another model."); + if (kind == "image" && mode == "image") + { + var edit = models.TryGetValue(id + "/edit", out var editModel) ? FalModelSchema.Parse(editModel, "image", entry.VerifiedAt, edit: true) : null; + if (edit == null) throw new InvalidOperationException($"Model '{id}' has no compatible image editing endpoint."); + entry.EditModelId = edit.Id; + entry.ImageInputField = edit.ImageInputField; + entry.ImageInputIsArray = edit.ImageInputIsArray; + entry.EditSupportsNumImages = edit.SupportsNumImages; + entry.EditOutputFormat = edit.OutputFormat; + } + return entry; + } + + private static string CachePath => CachePathOverrideForTests ?? Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "MCPForUnity", "fal-model-catalog.json"); + + private static void Load() + { + if (loaded) return; + loaded = true; + try + { + if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > 2 * 1024 * 1024) return; + var cache = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); + if (cache?.Version != 1 || cache.Snapshots == null) return; + foreach (var snapshot in cache.Snapshots) + { + if ((snapshot.Kind != "audio" && snapshot.Kind != "image") || snapshot.Entries == null + || snapshot.CheckedAt.Kind != DateTimeKind.Utc || snapshot.CheckedAt > UtcNow().AddMinutes(5) + || snapshot.Entries.Any(e => e == null || e.Provider != "fal" || e.Kind != snapshot.Kind || !FalModelSchema.SafeId(e.Id))) continue; + snapshot.FromDisk = true; + Snapshots[snapshot.Kind] = snapshot; + } + } + catch { /* Corrupt or old caches are ignored; never erase the bundled catalog. */ } + } + + private static void Save() + { + try + { + string path = CachePath; + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path + ".tmp", JsonConvert.SerializeObject(new Cache { Snapshots = Snapshots.Values.ToList() })); + if (File.Exists(path)) File.Replace(path + ".tmp", path, null); + else File.Move(path + ".tmp", path); + } + catch { /* A read-only/full disk does not invalidate the in-memory refresh. */ } + } + + internal static void ReloadCacheForTests() + { + Snapshots.Clear(); + loaded = false; + } + + internal static void ResetForTests(bool isolate = false) + { + Snapshots.Clear(); + Refreshes.Clear(); + Errors.Clear(); + Attempts.Clear(); + loaded = false; + TransportOverrideForTests = null; + CachePathOverrideForTests = isolate ? Path.Combine(Path.GetTempPath(), "unused_fal_catalog_" + Guid.NewGuid().ToString("N"), "catalog.json") : null; + UtcNow = () => DateTime.UtcNow; + nextRequestAt = DateTime.MinValue; + DelayOverrideForTests = isolate ? (_, __) => Task.CompletedTask : (Func)null; + Changed = null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta new file mode 100644 index 000000000..2dfa4d815 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a09083e5f5574e6a89e3265c845cb2d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs new file mode 100644 index 000000000..dc593f882 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs @@ -0,0 +1,187 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + /// + /// Conservative compatibility gate for fal's queue OpenAPI schemas. Only local references + /// and the request/response shapes understood by our adapters are accepted. An unknown + /// schema stays out of the selectable catalog instead of being guessed from a model name. + /// + internal static class FalModelSchema + { + internal static bool SafeId(string id) + => !string.IsNullOrEmpty(id) && id.Length <= 200 + && Regex.IsMatch(id, @"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)+$") + && !id.Split('/').Any(part => part == "." || part == ".."); + + internal static bool IsCandidate(JObject model, string kind) + { + if ((string)model?["metadata"]?["status"] != "active") return false; + if (kind != "audio") return (string)model?["metadata"]?["category"] == "text-to-image" + && !((string)model?["endpoint_id"] ?? "").Contains("text-to-vector"); + return (string)model?["metadata"]?["category"] == "text-to-audio" && AudioUseCase(model) != null; + } + + private static string AudioUseCase(JObject model) + { + string signals = ((string)model["endpoint_id"] + " " + string.Join(" ", model["metadata"]?["tags"]?.Values() ?? Enumerable.Empty())).ToLowerInvariant(); + if (signals.Contains("tts") || signals.Contains("speech") || signals.Contains("voice")) return null; + if (signals.Contains("stable-audio")) return "Music + SFX"; + if (signals.Contains("sound-effect") || signals.Contains("sfx")) return "Sound effects"; + if (signals.Contains("music") || signals.Contains("lyria")) return "Background music"; + return null; + } + + internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, bool edit = false) + { + string id = (string)model?["endpoint_id"]; + var metadata = model?["metadata"] as JObject; + if (!SafeId(id) || (string)metadata?["status"] != "active") return null; + string category = (string)metadata?["category"]; + if (category != (edit ? "image-to-image" : kind == "audio" ? "text-to-audio" : "text-to-image")) return null; + if (!edit && !IsCandidate(model, kind)) return null; + + var api = model["openapi"] as JObject; + var paths = api?["paths"] as JObject; + if (paths == null) return null; + // fal may expose an alias ID whose canonical OpenAPI path differs from that ID. + var submissions = paths.Properties().Where(p => p.Value["post"] != null).ToArray(); + if (submissions.Length != 1) return null; + var input = Resolve(api, submissions[0].Value["post"]?["requestBody"]?["content"]?["application/json"]?["schema"]); + var resultPath = paths.Properties().FirstOrDefault(p => p.Name.EndsWith("/requests/{request_id}", StringComparison.Ordinal)); + var output = Resolve(api, resultPath?.Value["get"]?["responses"]?["200"]?["content"]?["application/json"]?["schema"]); + var properties = input?["properties"] as JObject; + if (properties == null || input["allOf"] != null || input["oneOf"] != null || input["anyOf"] != null) return null; + + string promptField = properties["prompt"] != null ? "prompt" : kind == "audio" && properties["text"] != null ? "text" : null; + if (promptField == null || !IsString(api, properties[promptField])) return null; + + var entry = new ModelEntry + { + Id = id, Provider = "fal", Kind = kind, + Label = (string)metadata["display_name"] ?? id, + PromptField = promptField, FromRefresh = true, VerifiedAt = verifiedAt, + LicenseType = (string)metadata["license_type"], + ModelUrl = "https://fal.ai/models/" + id, + CommercialNote = "Review the model's license and provider terms before commercial use.", + SupportsNumImages = (string)Resolve(api, properties["num_images"])?["type"] == "integer" + && AcceptsOne(Resolve(api, properties["num_images"])), + SupportsImageSize = SupportsDimensions(api, properties["image_size"]), + }; + + if (kind == "audio") + { + entry.UseCase = AudioUseCase(model); + if (entry.UseCase == null) return null; + if (!HasFile(api, output, "audio") && !HasFile(api, output, "audio_file") + && (string)Resolve(api, output?["properties"]?["audio_url"])?["type"] != "string") return null; + + foreach (string field in new[] { "seconds_total", "duration", "duration_seconds", "music_length_ms" }) + { + if (properties[field] == null) continue; + var duration = Numeric(api, properties[field]); + if (duration == null || duration["exclusiveMinimum"] != null || duration["exclusiveMaximum"] != null) return null; + float scale = field == "music_length_ms" ? 1000f : 1f; + float min = (float?)duration["minimum"] ?? 1f * scale; + float max = (float?)duration["maximum"] ?? 0f; + if (!Finite(min) || !Finite(max) || min < 0 || max <= 0 || min > max) return null; + entry.DurationField = field; + entry.DurationScale = scale; + entry.DurationIsInteger = (string)duration["type"] == "integer"; + entry.MinDurationSeconds = min / scale; + entry.MaxDurationSeconds = max / scale; + // Keep the interactive default short, while respecting the live schema. + float defaultSeconds = ((float?)duration["default"] ?? 10f * scale) / scale; + if (!Finite(defaultSeconds)) return null; + entry.DefaultDurationSeconds = Math.Min(max / scale, Math.Max(min / scale, Math.Min(30f, defaultSeconds))); + break; + } + } + else + { + entry.UseCase = edit ? "Image editing" : "General image"; + if (properties["output_format"] != null) + { + var format = Resolve(api, properties["output_format"]); + var formats = format?["enum"]?.Values().ToArray(); + entry.OutputFormat = formats?.Contains("png") == true ? "png" + : formats?.Contains("jpeg") == true ? "jpeg" : formats?.Contains("jpg") == true ? "jpg" : null; + if (entry.OutputFormat == null) return null; + } + var images = Resolve(api, output?["properties"]?["images"]); + bool imageArray = (string)images?["type"] == "array" + && (string)Resolve(api, Resolve(api, images["items"])?["properties"]?["url"])?["type"] == "string"; + if (!imageArray && !HasFile(api, output, "image")) return null; + if (edit) + { + string field = properties["image_urls"] != null ? "image_urls" : properties["image_url"] != null ? "image_url" : null; + var imageInput = Resolve(api, field == null ? null : properties[field]); + bool array = (string)imageInput?["type"] == "array" && (string)Resolve(api, imageInput["items"])?["type"] == "string"; + if (field == null || (!array && (string)imageInput?["type"] != "string") + || array && (((int?)imageInput["minItems"] ?? 1) > 1 || ((int?)imageInput["maxItems"] ?? 1) < 1)) return null; + entry.ImageInputField = field; + entry.ImageInputIsArray = array; + } + } + + foreach (string required in input["required"]?.Values() ?? Enumerable.Empty()) + { + if (required == promptField || required == entry.DurationField || edit && required == entry.ImageInputField) continue; + if (kind == "image" && required == "num_images" && entry.SupportsNumImages) continue; + if (kind == "image" && required == "output_format" && entry.OutputFormat != null) continue; + return null; + } + return entry; + } + + private static bool Finite(float number) => !float.IsNaN(number) && !float.IsInfinity(number); + + private static bool IsString(JObject api, JToken token) + { + var schema = Resolve(api, token); + return (string)schema?["type"] == "string" || schema?["anyOf"] is JArray options + && options.Any(option => (string)Resolve(api, option)?["type"] == "string"); + } + + private static bool AcceptsOne(JObject schema) + => schema != null && ((float?)schema["minimum"] ?? 1f) <= 1f + && ((float?)schema["maximum"] ?? 1f) >= 1f && schema["enum"] == null; + + private static bool SupportsDimensions(JObject api, JToken token) + { + var schema = Resolve(api, token); + if (schema == null) return false; + if (schema["anyOf"] is JArray options) return options.Any(option => SupportsDimensions(api, option)); + return (string)Resolve(api, schema["properties"]?["width"])?["type"] == "integer" + && (string)Resolve(api, schema["properties"]?["height"])?["type"] == "integer"; + } + + private static bool HasFile(JObject api, JObject output, string field) + => (string)Resolve(api, Resolve(api, output?["properties"]?[field])?["properties"]?["url"])?["type"] == "string"; + + private static JObject Numeric(JObject api, JToken token) + { + var schema = Resolve(api, token); + if (schema == null) return null; + string type = (string)schema["type"]; + if (type == "integer" || type == "number") return schema; + return (schema["anyOf"] as JArray)?.Select(option => Numeric(api, option)).FirstOrDefault(option => option != null); + } + + private static JObject Resolve(JObject api, JToken token) + { + var schema = token as JObject; + for (int depth = 0; schema?["$ref"] != null && depth < 8; depth++) + { + string reference = (string)schema["$ref"]; + const string prefix = "#/components/schemas/"; + if (!reference.StartsWith(prefix, StringComparison.Ordinal)) return null; + schema = api?["components"]?["schemas"]?[reference.Substring(prefix.Length)] as JObject; + } + return schema?["$ref"] == null ? schema : null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta new file mode 100644 index 000000000..4d6995507 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26ab28b6bf5f41c5911d38bf1e4e1610 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs index 2b654c2b7..561ee1a3b 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs @@ -12,6 +12,7 @@ public sealed class HttpResult public byte[] Body; public string Text; public bool IsSuccess; + public int? RetryAfterSeconds; /// True when the transport reports success or the status code is 2xx. public bool Ok => IsSuccess || (Status >= 200 && Status < 300); diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs index 5b0a0f100..ce135375b 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs @@ -63,7 +63,8 @@ public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) Status = (int)request.responseCode, Body = request.downloadHandler?.data, Text = request.downloadHandler?.text, - IsSuccess = request.result == UnityWebRequest.Result.Success + IsSuccess = request.result == UnityWebRequest.Result.Success, + RetryAfterSeconds = int.TryParse(request.GetResponseHeader("Retry-After"), out int retryAfter) ? (int?)retryAfter : null }; tcs.TrySetResult(result); } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs index 35ec7ce65..8b2db4ae1 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -18,8 +18,7 @@ public sealed class FalAdapter : IImageProviderAdapter { private const string QueueBase = "https://queue.fal.run/"; private const string QueueHost = "queue.fal.run"; - // FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives: - // fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality). + // Bundled bootstrap default. The shared live catalog may remove it or offer newer models. // internal so the model catalog references it directly (single source of truth, drift-guarded). internal const string DefaultModel = "fal-ai/flux-2"; @@ -34,15 +33,21 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); - var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 }; + ModelEntry entry = req.CatalogEntry ?? AssetGenModelCatalog.Find(model); + var body = new JObject { [entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty }; + if (entry == null || (image ? entry.EditSupportsNumImages : entry.SupportsNumImages)) body["num_images"] = 1; + string outputFormat = image ? entry?.EditOutputFormat : entry?.OutputFormat; + if (outputFormat != null) body["output_format"] = outputFormat; string url; if (image) { // image→image / editing lives on the model's /edit endpoint and takes an image_urls // array; each entry accepts a hosted URL or an inline base64 data URI (local image_path). - url = QueueBase + model + "/edit"; + if (entry?.FromRefresh == true && string.IsNullOrEmpty(entry.EditModelId)) + throw new Exception($"Model '{model}' has no verified image editing endpoint."); + url = QueueBase + (entry?.EditModelId ?? model + "/edit"); string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); - body["image_urls"] = new JArray(imageRef); + body[entry?.ImageInputField ?? "image_urls"] = entry?.ImageInputIsArray != false ? (JToken)new JArray(imageRef) : (JToken)imageRef; } else { @@ -51,6 +56,8 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT // Forward explicit output dimensions for text→image only; fal's image_size accepts a // {width,height} object. (/edit derives size from the source image and may reject it. // FLUX has no transparency param — transparent backgrounds aren't a generation-time option.) + if (!image && req.Width > 0 && req.Height > 0 && entry?.SupportsImageSize == false) + throw new Exception($"Model '{model}' does not support width/height image_size parameters."); if (!image && req.Width > 0 && req.Height > 0) body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs index dbf682ca0..39d3de5fe 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs @@ -11,8 +11,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// - /// fal.ai audio provider via the queue API. One adapter fronts every v1 audio model - /// (stable-audio-25, cassetteai/*, lyria2); the model id in + /// fal.ai audio provider via the queue API. The captured catalog profile describes each + /// compatible model's prompt and duration fields; /// selects the endpoint. Submits to queue.fal.run/{model} (auth header /// "Authorization: Key <key>"), polls status, then returns the result audio URL for the job /// manager to download. Reuses the single existing "fal" secure key. @@ -69,17 +69,17 @@ public async Task SubmitAsync(AudioGenRequest req, string apiKey, IHttpT // prompt-only. private static JObject BuildBody(string model, AudioGenRequest req) { - var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty }; - - ModelEntry entry = AssetGenModelCatalog.Find(model); + ModelEntry entry = req.CatalogEntry ?? AssetGenModelCatalog.Find(model); + var body = new JObject { [entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty }; if (entry != null && !string.IsNullOrEmpty(entry.DurationField)) { float dur = req.Duration > 0f ? req.Duration : entry.DefaultDurationSeconds; - float floor = Math.Max(1f, entry.MinDurationSeconds); + float floor = entry.FromRefresh ? entry.MinDurationSeconds : Math.Max(1f, entry.MinDurationSeconds); dur = Math.Min(Math.Max(dur, floor), entry.MaxDurationSeconds); - // Floor (not round) so we never exceed the requested duration, then enforce >= 1. - int seconds = Math.Max(1, (int)Math.Floor(dur)); - body[entry.DurationField] = seconds; + float units = dur * entry.DurationScale; + body[entry.DurationField] = entry.DurationIsInteger + ? (JToken)(long)Math.Max(Math.Ceiling(floor * entry.DurationScale), Math.Floor(units)) + : (JToken)units; } return body; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs index 1e8d0f9e1..51c18386c 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -48,6 +48,7 @@ public sealed class ModelGenRequest /// Request to generate a 2D image. Shared by every image provider adapter. public sealed class ImageGenRequest { + internal ModelEntry CatalogEntry; public string Provider; public string Mode; // text | image public string Prompt; @@ -69,6 +70,7 @@ public sealed class ImageGenRequest /// public sealed class AudioGenRequest { + internal ModelEntry CatalogEntry; public string Provider; // "fal" for v1 public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio public string Prompt; diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs index e2955f82a..ddde8e585 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.AssetGen; using MCPForUnity.Editor.Services.AssetGen.Providers; @@ -62,5 +63,42 @@ public static object ListProviders(string kind) } return new SuccessResponse($"{list.Count} {kind} provider(s).", new { providers = list }); } + + /// Return the same model snapshot as the panel; refreshes run without blocking. + public static object ListModels(ToolParams p, string kind, bool forceRefresh = false) + { + string provider = p.Get("provider")?.ToLowerInvariant(); + var providers = AssetGenProviders.List().Where(info => info.Kind == kind && (string.IsNullOrEmpty(provider) || info.Id == provider)).ToList(); + if (providers.Count == 0) return new ErrorResponse($"Unknown {kind} provider '{provider}'."); + if (forceRefresh && !providers.Any(info => info.Id == "fal")) + return new ErrorResponse("Live model refresh currently supports fal image and audio. Other providers use the bundled catalog."); + var models = new List(); + var catalogs = new List(); + foreach (var info in providers) + { + bool live = info.Id == "fal" && (kind == "image" || kind == "audio"); + if (live) _ = FalModelCatalog.RefreshAsync(kind, forceRefresh); + foreach (var model in AssetGenModelCatalog.ForProvider(info.Id, kind)) + models.Add(new + { + id = model.Id, label = model.Label, provider = model.Provider, kind = model.Kind, + use_case = model.UseCase, verified_at = model.VerifiedAt, + status = model.FromRefresh ? "verified" : "unverified", + capabilities = kind == "model" ? info.Capabilities : kind == "image" && (!model.FromRefresh || !string.IsNullOrEmpty(model.EditModelId)) + ? new[] { "text", "image" } : new[] { "text" }, + max_duration_seconds = model.MaxDurationSeconds, license_type = model.LicenseType, + }); + catalogs.Add(new + { + provider = info.Id, source = live ? FalModelCatalog.Source(kind) : "bundled", + last_verified = live ? FalModelCatalog.VerifiedAt(kind) : null, + stale = !live || FalModelCatalog.IsStale(kind), + refreshing = live && FalModelCatalog.IsRefreshing(kind), + refresh_error = live ? FalModelCatalog.LastError(kind) : null, + }); + } + return new SuccessResponse("Model catalog. If refreshing is true, call list_models again after the refresh completes. Bundled entries are unverified; fal endpoints are rechecked before generation.", + new { models, catalogs }); + } } } diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs index 598d7c443..99219943f 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs @@ -30,9 +30,11 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "Audio", 3.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("audio"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "audio"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "audio", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) @@ -57,9 +59,8 @@ private static object Generate(ToolParams p) if (string.IsNullOrWhiteSpace(prompt)) return new ErrorResponse("'prompt' is required for audio generation."); - // Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own - // default; a resolved id is passed through verbatim (the catalog default equals the - // adapter constant, so an omitted model is a no-op either way). + // Empty -> GUI-selected model -> current catalog default. Preserve the resolved ID; + // exact availability and the live request profile are checked before submission. string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model")); var req = new AudioGenRequest diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs index 1da99d563..2fbfd8a06 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs @@ -31,9 +31,11 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "Image", 2.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("image"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "image"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "image", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs index 718c92a94..41155b6eb 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -30,9 +30,10 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "3D model", 3.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("model"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "model"); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models."); } } catch (NotSupportedException nse) diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 6b0e6d113..7f03a3cd2 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; @@ -48,6 +50,7 @@ private static readonly (string Id, string Label)[] ImageProviders = // Per-provider enable toggles for the GLB-capable (model) providers, used to // recompute the glTFast notice when a toggle changes. private readonly List<(string Id, Toggle Toggle)> modelEnableToggles = new(); + private readonly List<(VisualElement Container, string Kind, string Provider)> modelControls = new(); public VisualElement Root { get; private set; } @@ -57,6 +60,10 @@ public McpAssetGenSection(VisualElement root) CacheUIElements(); InitializeUI(); RegisterCallbacks(); + Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnCatalogChanged; FalModelCatalog.Changed += OnCatalogChanged; }); + Root.RegisterCallback(_ => FalModelCatalog.Changed -= OnCatalogChanged); + if (Root.panel != null) FalModelCatalog.Changed += OnCatalogChanged; + _ = RefreshCatalog(false); } private void CacheUIElements() @@ -124,29 +131,57 @@ private void RegisterCallbacks() if (refreshButton != null) { refreshButton.tooltip = - "Re-check API-key presence and rebuild the provider/model rows. Picks up keys or " + - "prefs set elsewhere (CLI, env override). The model list is curated in-package."; + "Fetch current fal image and sound models and re-check key presence. Other providers use the bundled catalog."; refreshButton.clicked += OnRefreshClicked; } } /// - /// Re-reads secure-store key presence and the curated catalog and rebuilds the rows — useful - /// to pick up keys/prefs set elsewhere (CLI, env override). fal has no public list-models API, - /// so the curated catalog is the source of truth; this never hits the network or blocks the tab. + /// Re-reads key presence and forces a nonblocking fal catalog refresh. /// private void OnRefreshClicked() { SyncFromPrefs(); - if (refreshStatusLabel != null) - SetStatus(refreshStatusLabel, "refreshed — using the built-in model catalog", true); + _ = RefreshCatalog(true); } /// /// Re-reads secure-store presence and prefs and rebuilds the rows. Called when the /// tab becomes visible so keys set elsewhere (e.g. via CLI) are reflected. /// - public void Refresh() => SyncFromPrefs(); + public void Refresh() + { + SyncFromPrefs(); + _ = RefreshCatalog(false); + } + + private async Task RefreshCatalog(bool force) + { + refreshButton?.SetEnabled(false); + if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, "Checking fal model catalog…", true); + try + { + await Task.WhenAll(FalModelCatalog.RefreshAsync("image", force), FalModelCatalog.RefreshAsync("audio", force)); + // Only replace model controls: an automatic refresh must preserve unsaved API-key input. + OnCatalogChanged(null); + string error = FalModelCatalog.LastError("image") ?? FalModelCatalog.LastError("audio"); + string verified = FalModelCatalog.VerifiedAt("audio") ?? FalModelCatalog.VerifiedAt("image"); + string when = DateTime.TryParse(verified, out var time) ? time.ToLocalTime().ToString("g") : "unknown"; + string label = error != null ? error : "fal models verified " + when + " · other providers use bundled models"; + if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, label, error == null); + } + finally { refreshButton?.SetEnabled(true); } + } + + private void OnCatalogChanged(string kind) + { + foreach (var control in modelControls.ToArray()) + { + if (control.Provider != "fal" || kind != null && control.Kind != kind) continue; + control.Container.Clear(); + PopulateModelDropdown(control.Container, control.Kind, control.Provider); + } + } /// Rebuild the provider rows and reflect current prefs into the fields. private void SyncFromPrefs() @@ -167,6 +202,7 @@ private void BuildProviderRows() providersContainer.Clear(); modelEnableToggles.Clear(); + modelControls.Clear(); var modelPanel = AddCategoryPanel("3D Models"); foreach (var provider in ModelProviders) @@ -387,25 +423,30 @@ void SaveKeyFromField() /// Selecting a model becomes the default that generate_* uses when no explicit model is passed. /// private void AddModelDropdown(VisualElement parent, string kind, string providerId) + { + var container = new VisualElement(); + parent.Add(container); + modelControls.Add((container, kind, providerId)); + PopulateModelDropdown(container, kind, providerId); + } + + private void PopulateModelDropdown(VisualElement parent, string kind, string providerId) { IReadOnlyList models = AssetGenModelCatalog.ForProvider(providerId, kind); - if (models.Count == 0) return; + if (models.Count == 0) + { + if (providerId == "fal") parent.Add(new Label("No compatible models found. Refresh the catalog to check again.")); + return; + } var choices = new List(); - foreach (ModelEntry m in models) choices.Add(m.Label); + foreach (ModelEntry m in models) choices.Add(m.Label + " (" + m.Id + ")"); string selectedId = AssetGenPrefs.GetSelectedModel(kind, providerId); if (string.IsNullOrEmpty(selectedId)) selectedId = AssetGenModelCatalog.DefaultModelId(providerId, kind); - ModelEntry selected = AssetGenModelCatalog.Find(selectedId); - if (selected == null) - { - // The stored pref points at a model that's no longer in the catalog (stale/invalid). - // The dropdown falls back to the first model — clear the pref so generate_* resolves to - // the same shown model instead of sending the stale id. - selected = models[0]; - if (!string.IsNullOrEmpty(AssetGenPrefs.GetSelectedModel(kind, providerId))) - AssetGenPrefs.SetSelectedModel(kind, providerId, string.Empty); - } + ModelEntry selected = models.FirstOrDefault(model => model.Id == selectedId); + int selectedIndex = selected == null ? choices.Count : models.ToList().IndexOf(selected); + if (selected == null) choices.Add("Saved model unavailable — choose another (" + selectedId + ")"); // Lay the dropdown out like the Format row: a horizontal .setting-row (align-items:center, // min-height:24px) with a .setting-label + a label-less DropdownField. Adding the dropdown @@ -420,7 +461,7 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider var dropdown = new DropdownField(choices, 0); dropdown.AddToClassList("setting-dropdown-inline"); dropdown.tooltip = "The model generate_* uses for this provider when no explicit model is passed."; - dropdown.SetValueWithoutNotify(selected.Label); + dropdown.SetValueWithoutNotify(choices[selectedIndex]); dropdownRow.Add(dropdown); parent.Add(dropdownRow); @@ -435,13 +476,18 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider caveat.style.whiteSpace = WhiteSpace.Normal; parent.Add(caveat); - UpdateModelMeta(meta, selected); - UpdateModelCaveat(caveat, selected); + if (selected != null) + { + UpdateModelMeta(meta, selected); + UpdateModelCaveat(caveat, selected); + } + else meta.text = "Your saved selection is preserved. Choose an available model before generating."; dropdown.RegisterValueChangedCallback(evt => { - ModelEntry picked = FindByLabel(models, evt.newValue); - if (picked == null) return; + int index = choices.IndexOf(evt.newValue); + if (index < 0 || index >= models.Count) return; + ModelEntry picked = models[index]; AssetGenPrefs.SetSelectedModel(kind, providerId, picked.Id); UpdateModelMeta(meta, picked); UpdateModelCaveat(caveat, picked); @@ -499,13 +545,6 @@ private void RebuildIfSharedKey(string id) Root?.schedule.Execute(SyncFromPrefs); } - private static ModelEntry FindByLabel(IReadOnlyList models, string label) - { - foreach (ModelEntry m in models) - if (m.Label == label) return m; - return null; - } - private static void UpdateModelMeta(Label label, ModelEntry m) { if (label == null || m == null) return; @@ -516,7 +555,10 @@ private static void UpdateModelMeta(Label label, ModelEntry m) // Lyria advertises a max but takes no duration input, so showing a hint would mislead. if (m.MaxDurationSeconds > 0f && !string.IsNullOrEmpty(m.DurationField)) parts.Add($"≤{m.MaxDurationSeconds:0}s"); if (m.Loopable) parts.Add("loopable"); + parts.Add(m.FromRefresh ? "verified catalog" : "bundled · unverified"); + if (!string.IsNullOrEmpty(m.LicenseType)) parts.Add("license: " + m.LicenseType); label.text = string.Join(" · ", parts); + label.tooltip = m.ModelUrl ?? m.Id; } private static void UpdateModelCaveat(Label label, ModelEntry m) diff --git a/Server/src/cli/commands/asset_gen.py b/Server/src/cli/commands/asset_gen.py index 99b41d77b..d6928ab3f 100644 --- a/Server/src/cli/commands/asset_gen.py +++ b/Server/src/cli/commands/asset_gen.py @@ -28,6 +28,27 @@ def _emit(result, config, verb): print_info(f"{verb} started. Poll with: unity-mcp asset-gen status --job-id {job_id}") +@asset_gen.command("list-models") +@click.option("--kind", type=click.Choice(["audio", "image", "model"]), required=True, + help="Asset kind to discover.") +@click.option("--provider", default=None, help="Restrict results to one provider.") +@click.option("--refresh", is_flag=True, help="Force a background fal catalog refresh (audio/image only).") +@handle_unity_errors +def list_models(kind: str, provider: Optional[str], refresh: bool): + """List compatible models, capabilities, and catalog freshness. + + If catalogs[].refreshing is true, repeat this command without --refresh later. + Tripo, Meshy and OpenRouter currently return bundled, unverified entries. + """ + if refresh and (kind == "model" or provider not in (None, "fal")): + raise click.UsageError("--refresh currently supports fal audio and image models.") + params = {"action": "refresh_models" if refresh else "list_models"} + if provider is not None: + params["provider"] = provider + config = get_config() + click.echo(format_output(run_command(f"generate_{kind}", params, config), config.format)) + + @asset_gen.command("generate-model") @click.option("--provider", default=None, help="Provider id (tripo, meshy).") @click.option("--mode", default=None, help="Generation mode: text or image.") diff --git a/Server/src/services/tools/generate_audio.py b/Server/src/services/tools/generate_audio.py index 04466f837..119529616 100644 --- a/Server/src/services/tools/generate_audio.py +++ b/Server/src/services/tools/generate_audio.py @@ -22,9 +22,8 @@ "Generate audio (sound effects and background music) with fal.ai models and import " "them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in " "the editor's secure store (shared with image generation) and never crosses the bridge.\n\n" - "MODELS (all via fal.ai): fal-ai/stable-audio-25/text-to-audio (music + SFX, <=190s), " - "cassetteai/sound-effects-generator (SFX, <=30s), cassetteai/music-generator (music), " - "fal-ai/lyria2 (music). Omit model to use the model selected in the " + "Use list_models to discover current compatible sound/music models, their duration " + "limits and catalog freshness. Omit model to use the model selected in the " "MCP for Unity -> Asset Generation tab.\n\n" "ACTIONS:\n" "- generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the " @@ -32,7 +31,10 @@ "output_folder.\n" "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" - "- list_providers: List configured audio providers and capabilities (no key values)." + "- list_providers: List configured audio providers and capabilities (no key values).\n" + "- list_models: List models from the editor's shared catalog; refresh stale fal data " + "in the background. If catalogs[].refreshing is true, call list_models again later.\n" + "- refresh_models: Force a background fal catalog refresh; returns the current snapshot." ), annotations=ToolAnnotations( title="Generate Audio", @@ -41,12 +43,12 @@ ) async def generate_audio( ctx: Context, - action: Annotated[Literal["generate", "status", "cancel", "list_providers"], + action: Annotated[Literal["generate", "status", "cancel", "list_providers", "list_models", "refresh_models"], "Action to perform."], provider: Annotated[str, "Provider id (fal)."] | None = None, prompt: Annotated[str, "Text prompt describing the sound or music."] | None = None, - model: Annotated[str, "fal model id (e.g. fal-ai/stable-audio-25/text-to-audio). " + model: Annotated[str, "fal model id returned by list_models. " "Omit to use the GUI-selected default."] | None = None, duration: Annotated[float, "Requested length in seconds (soft-clamped per model)."] | None = None, name: Annotated[str, "Base name for the imported asset."] | None = None, diff --git a/Server/src/services/tools/generate_image.py b/Server/src/services/tools/generate_image.py index 5ba96666c..0f013786c 100644 --- a/Server/src/services/tools/generate_image.py +++ b/Server/src/services/tools/generate_image.py @@ -30,7 +30,11 @@ "- remove_background: Unsupported in this version; returns an error instead of a job_id.\n" "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" - "- list_providers: List configured image providers and capabilities (no key values)." + "- list_providers: List configured image providers and capabilities (no key values).\n" + "- list_models: List models and supported modes from the editor's shared catalog; " + "refresh stale fal data in the background. If catalogs[].refreshing is true, call " + "list_models again later. OpenRouter uses the bundled catalog.\n" + "- refresh_models: Force a background fal catalog refresh; returns the current snapshot." ), annotations=ToolAnnotations( title="Generate Image", @@ -39,7 +43,7 @@ ) async def generate_image( ctx: Context, - action: Annotated[Literal["generate", "remove_background", "status", "cancel", "list_providers"], + action: Annotated[Literal["generate", "remove_background", "status", "cancel", "list_providers", "list_models", "refresh_models"], "Action to perform."], provider: Annotated[str, "Provider id (fal, openrouter)."] | None = None, @@ -47,7 +51,7 @@ async def generate_image( prompt: Annotated[str, "Text prompt for text->image."] | None = None, image_path: Annotated[str, "Path to a source image for image->image mode."] | None = None, image_url: Annotated[str, "URL of a source image for image->image."] | None = None, - model: Annotated[str, "Provider model id/slug (e.g. FLUX, gemini-2.5-flash-image)."] | None = None, + model: Annotated[str, "Provider model id/slug returned by list_models; omit for the GUI-selected default."] | None = None, transparent: Annotated[bool, "Mark the imported texture as alpha-is-transparency. NOTE: fal/FLUX " "and OpenRouter have no generation-time transparency, so this only sets the " "Unity import flag — it does not make the model render a transparent background."] | None = None, diff --git a/Server/src/services/tools/generate_model.py b/Server/src/services/tools/generate_model.py index 0befe52a3..e2e32d39c 100644 --- a/Server/src/services/tools/generate_model.py +++ b/Server/src/services/tools/generate_model.py @@ -29,7 +29,9 @@ "tier, model, name, output_folder.\n" "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" - "- list_providers: List configured 3D providers and capabilities (no key values)." + "- list_providers: List configured 3D providers and capabilities (no key values).\n" + "- list_models: List bundled 3D models. Live discovery is currently limited to " + "fal image/audio; 3D entries are marked unverified." ), annotations=ToolAnnotations( title="Generate Model", @@ -38,7 +40,7 @@ ) async def generate_model( ctx: Context, - action: Annotated[Literal["generate", "status", "cancel", "list_providers"], + action: Annotated[Literal["generate", "status", "cancel", "list_providers", "list_models"], "Action to perform."], provider: Annotated[str, "Provider id (tripo, meshy)."] | None = None, diff --git a/Server/tests/test_asset_gen_catalog.py b/Server/tests/test_asset_gen_catalog.py new file mode 100644 index 000000000..c7577511b --- /dev/null +++ b/Server/tests/test_asset_gen_catalog.py @@ -0,0 +1,63 @@ +"""Model discovery actions retain catalog state across the MCP/CLI boundary.""" + +import asyncio +import importlib +from typing import get_args, get_type_hints +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from cli.commands.asset_gen import asset_gen +from cli.utils.config import CLIConfig + + +@pytest.mark.parametrize("kind,action", [ + ("audio", "list_models"), ("audio", "refresh_models"), + ("image", "list_models"), ("image", "refresh_models"), + ("model", "list_models"), +]) +def test_discovery_actions_are_advertised_and_preserve_catalog_response(kind, action): + module = importlib.import_module(f"services.tools.generate_{kind}") + tool = getattr(module, f"generate_{kind}") + action_type = get_args(get_type_hints(tool, include_extras=True)["action"])[0] + assert action in get_args(action_type) + catalog = {"success": True, "data": { + "models": [{"id": "test/music", "status": "verified"}], + "catalogs": [{"source": "cache", "stale": True, "refreshing": True, + "refresh_error": "HTTP 429", "last_verified": "2026-10-01T12:00:00Z"}], + }} + with patch.object(module, "get_unity_instance_from_context", AsyncMock(return_value="unity-1")), \ + patch.object(module, "send_with_unity_instance", AsyncMock(return_value=catalog)) as send: + result = asyncio.run(tool(MagicMock(), action=action, provider="fal" if kind != "model" else "tripo")) + assert result == catalog + assert send.call_args.args[2] == f"generate_{kind}" + assert send.call_args.args[3] == {"action": action, "provider": "fal" if kind != "model" else "tripo"} + + +@pytest.mark.parametrize("kind,refresh", [("audio", False), ("image", True), ("model", False)]) +def test_cli_lists_catalog_without_generation_or_key_parameters(kind, refresh): + config = CLIConfig(host="127.0.0.1", port=8080, timeout=30, format="json", unity_instance=None) + arguments = ["list-models", "--kind", kind, "--provider", "fal" if kind != "model" else "meshy"] + if refresh: + arguments.append("--refresh") + with patch("cli.commands.asset_gen.get_config", return_value=config), \ + patch("cli.commands.asset_gen.run_command", return_value={"success": True, "data": {"models": []}}) as run: + result = CliRunner().invoke(asset_gen, arguments) + assert result.exit_code == 0, result.output + assert run.call_args.args == (f"generate_{kind}", { + "action": "refresh_models" if refresh else "list_models", + "provider": "fal" if kind != "model" else "meshy", + }, config) + + +@pytest.mark.parametrize("arguments", [ + ["list-models", "--kind", "model", "--refresh"], + ["list-models", "--kind", "image", "--provider", "openrouter", "--refresh"], +]) +def test_cli_rejects_unsupported_live_refresh_before_contacting_unity(arguments): + with patch("cli.commands.asset_gen.run_command") as run: + result = CliRunner().invoke(asset_gen, arguments) + assert result.exit_code != 0 + assert "currently supports fal" in result.output + run.assert_not_called() diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs index ae073c57f..05dee162e 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs @@ -30,6 +30,8 @@ public class AssetGenJobManagerTests public void SetUp() { AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + AssetGenJobManager.SkipModelVerificationForTests = true; Environment.SetEnvironmentVariable(EnvVar, Secret); Environment.SetEnvironmentVariable(FalEnvVar, FalSecret); _fake = new FakeHttpTransport(); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs index 9ef048e6c..3560dc8ad 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs @@ -12,6 +12,11 @@ namespace MCPForUnityTests.Editor.AssetGen /// public class AssetGenModelCatalogTests { + [SetUp] + public void SetUp() => AssetGenModelCatalog.ResetForTests(true); + + [TearDown] + public void TearDown() => AssetGenModelCatalog.ResetForTests(); [Test] public void Curated_HasAllFourAudioModels() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs new file mode 100644 index 000000000..75bf02728 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using MCPForUnity.Editor.Tools.AssetGen; +using MCPForUnity.Editor.Windows.Components.AssetGen; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine.TestTools; +using UnityEngine.UIElements; + +namespace MCPForUnityTests.Editor.AssetGen +{ + public class FalModelCatalogTests + { + private const string Music = "test/music-v2"; + private string directory; + private string oldSelection; + private DateTime now; + private FakeHttpTransport http; + + [SetUp] + public void SetUp() + { + AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + oldSelection = AssetGenPrefs.GetSelectedModel("audio", "fal"); + AssetGenPrefs.SetSelectedModel("audio", "fal", ""); + directory = Path.Combine(Path.GetTempPath(), "fal_catalog_" + Guid.NewGuid().ToString("N")); + now = new DateTime(2026, 10, 3, 12, 0, 0, DateTimeKind.Utc); + FalModelCatalog.UtcNow = () => now; + FalModelCatalog.CachePathOverrideForTests = Path.Combine(directory, "catalog.json"); + http = new FakeHttpTransport(); + FalModelCatalog.TransportOverrideForTests = http; + Serve(Endpoint(Music)); + } + + [TearDown] + public void TearDown() + { + AssetGenJobManager.ResetForTests(); + AssetGenPrefs.SetSelectedModel("audio", "fal", oldSelection); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + internal static JObject Endpoint(string id, string kind = "audio", string prompt = "prompt", bool edit = false) + { + var input = new JObject { ["type"] = "object", ["properties"] = new JObject { [prompt] = new JObject { ["type"] = "string" } }, ["required"] = new JArray(prompt) }; + var output = new JObject { ["type"] = "object", ["properties"] = kind == "audio" + ? new JObject { ["audio"] = new JObject { ["$ref"] = "#/components/schemas/File" } } + : new JObject { ["images"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["$ref"] = "#/components/schemas/File" } } } }; + if (edit) + { + input["properties"]["image_url"] = new JObject { ["type"] = "string" }; + ((JArray)input["required"]).Add("image_url"); + } + return new JObject + { + ["endpoint_id"] = id, + ["metadata"] = new JObject { ["status"] = "active", ["category"] = edit ? "image-to-image" : kind == "audio" ? "text-to-audio" : "text-to-image", ["display_name"] = "Test model", ["tags"] = new JArray(kind == "audio" ? "music" : "image"), ["updated_at"] = "2026-10-01T12:00:00Z" }, + ["openapi"] = new JObject + { + ["paths"] = new JObject + { + ["/" + id] = new JObject { ["post"] = new JObject { ["requestBody"] = new JObject { ["content"] = new JObject { ["application/json"] = new JObject { ["schema"] = new JObject { ["$ref"] = "#/components/schemas/Input" } } } } } }, + ["/" + id + "/requests/{request_id}"] = new JObject { ["get"] = new JObject { ["responses"] = new JObject { ["200"] = new JObject { ["content"] = new JObject { ["application/json"] = new JObject { ["schema"] = new JObject { ["$ref"] = "#/components/schemas/Output" } } } } } } }, + }, + ["components"] = new JObject { ["schemas"] = new JObject { ["Input"] = input, ["Output"] = output, ["File"] = new JObject { ["type"] = "object", ["properties"] = new JObject { ["url"] = new JObject { ["type"] = "string" } } } } }, + }, + }; + } + + private static JObject Input(JObject model) => (JObject)model["openapi"]["components"]["schemas"]["Input"]; + private static HttpResult Json(JObject json) => new HttpResult { Status = 200, Text = json.ToString() }; + private static HttpResult Models(params JObject[] models) => Json(new JObject { ["models"] = new JArray(models), ["has_more"] = false }); + private void Serve(params JObject[] models) + { + http.Handler = request => + { + bool details = request.Url.Contains("endpoint_id="); + string category = request.Url.Contains("category=text-to-image") ? "text-to-image" : "text-to-audio"; + return Models(models.Where(model => details + ? request.Url.Contains("endpoint_id=" + Uri.EscapeDataString((string)model["endpoint_id"]) + "&") + : (string)model["metadata"]["category"] == category).ToArray()); + }; + } + private bool Refresh(string kind = "audio", bool force = true) => FalModelCatalog.RefreshAsync(kind, force).GetAwaiter().GetResult(); + + [Test] + public void Refresh_DiscoversModels_WithoutKeys_AndSharesToolCatalog() + { + Assert.IsTrue(Refresh()); + Assert.AreEqual(Music, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + Assert.IsTrue(AssetGenModelCatalog.Find(Music).FromRefresh); + Assert.IsTrue(http.RecordedRequests.All(request => request.Method == "GET" && !request.Headers.ContainsKey("Authorization"))); + var response = JObject.FromObject(GenerateAudio.HandleCommand(new JObject { ["action"] = "list_models" })); + Assert.AreEqual(Music, (string)response["data"]["models"][0]["id"]); + Assert.AreEqual("live", (string)response["data"]["catalogs"][0]["source"]); + Assert.AreEqual(false, (bool)response["data"]["catalogs"][0]["stale"]); + Assert.IsFalse(File.ReadAllText(FalModelCatalog.CachePathOverrideForTests).Contains("Authorization")); + } + + [Test] + public void Refresh_RemovesDeprecatedBundledModels_WithoutResurrectingDefaults() + { + var removed = Endpoint(FalAudioAdapter.DefaultModel); + removed["metadata"]["status"] = "deprecated"; + Serve(removed); + Assert.IsTrue(Refresh()); + Assert.IsEmpty(AssetGenModelCatalog.ForProvider("fal", "audio")); + Assert.IsNull(AssetGenModelCatalog.Find(FalAudioAdapter.DefaultModel)); + Assert.Throws(() => AssetGenModelCatalog.ResolveModel("audio", "fal", null)); + FalModelCatalog.ReloadCacheForTests(); + Assert.IsEmpty(AssetGenModelCatalog.ForProvider("fal", "audio"), "Empty successful snapshots must survive reload."); + } + + [Test] + public void Refresh_FailureOnLaterPage_RetainsPreviousSnapshot() + { + Assert.IsTrue(Refresh()); + string checkedAt = FalModelCatalog.VerifiedAt("audio"); + http.Handler = request => request.Url.Contains("cursor=") + ? new HttpResult { Status = 429 } + : Json(new JObject { ["models"] = new JArray(), ["has_more"] = true, ["next_cursor"] = "page2" }); + now = now.AddDays(2); + Assert.IsFalse(Refresh()); + Assert.AreEqual(Music, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + Assert.AreEqual(checkedAt, FalModelCatalog.VerifiedAt("audio")); + StringAssert.Contains("429", FalModelCatalog.LastError("audio")); + Assert.IsTrue(FalModelCatalog.IsStale("audio")); + } + + [Test] + public void Refresh_TraversesAllMetadataPages_BeforeReplacingCatalog() + { + var next = Endpoint("test/music-v3"); + http.Handler = request => request.Url.Contains("endpoint_id=") ? Models(Endpoint(Music), next) + : request.Url.Contains("cursor=") ? Models(next) + : Json(new JObject { ["models"] = new JArray(Endpoint(Music)), ["has_more"] = true, ["next_cursor"] = "next page+" }); + Assert.IsTrue(Refresh()); + CollectionAssert.AreEquivalent(new[] { Music, "test/music-v3" }, AssetGenModelCatalog.ForProvider("fal", "audio").Select(entry => entry.Id)); + Assert.IsTrue(http.RecordedRequests.Any(request => request.Url.Contains("cursor=next%20page%2B"))); + } + + [TestCase("{\"models\":[] ,\"has_more\":true}")] + [TestCase("{\"wrong\":[]}")] + [TestCase("not json")] + public void Refresh_IncompleteOrMalformedResponse_DoesNotEraseCatalog(string body) + { + Assert.IsTrue(Refresh()); + http.Handler = _ => new HttpResult { Status = 200, Text = body }; + Assert.IsFalse(Refresh()); + Assert.AreEqual(Music, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + } + + [Test] + public void Refresh_SchemaExpansionError_RetainsSnapshot() + { + Assert.IsTrue(Refresh()); + var failed = Endpoint(Music); + failed["openapi"] = new JObject { ["error"] = new JObject { ["code"] = "expansion_failed" } }; + Serve(failed); + Assert.IsFalse(Refresh()); + Assert.IsNotNull(AssetGenModelCatalog.Find(Music)); + } + + [Test] + public void Refresh_FilterSpeechAndUnknownRequiredInputs() + { + var speech = Endpoint("test/tts-music", prompt: "text"); + var lyrics = Endpoint("test/music-lyrics"); + Input(lyrics)["properties"]["lyrics"] = new JObject { ["type"] = "string" }; + ((JArray)Input(lyrics)["required"]).Add("lyrics"); + Serve(Endpoint(Music), speech, lyrics); + Assert.IsTrue(Refresh()); + CollectionAssert.AreEqual(new[] { Music }, AssetGenModelCatalog.ForProvider("fal", "audio").Select(entry => entry.Id)); + } + + [Test] + public void FreshCache_SkipsNetwork_UntilTtlOrForcedRefresh() + { + Assert.IsTrue(Refresh()); + int count = http.RecordedRequests.Count; + FalModelCatalog.ReloadCacheForTests(); + Assert.AreEqual("cache", FalModelCatalog.Source("audio")); + now = now.AddHours(23); + Assert.IsTrue(Refresh(force: false)); + Assert.AreEqual(count, http.RecordedRequests.Count); + Assert.IsTrue(Refresh(force: true)); + now = now.AddHours(25); + Assert.IsTrue(Refresh(force: false)); + Assert.Greater(http.RecordedRequests.Count, count); + } + + [Test] + public void FailedAutomaticRefresh_BacksOff_ButManualRefreshRetries() + { + http.Handler = _ => new HttpResult { Status = 500 }; + Assert.IsFalse(Refresh(force: false)); + int requests = http.RecordedRequests.Count; + Assert.IsFalse(Refresh(force: false)); + Assert.AreEqual(requests, http.RecordedRequests.Count); + Assert.IsFalse(Refresh(force: true)); + Assert.Greater(http.RecordedRequests.Count, requests); + } + + [Test] + public void RateLimitedRequest_RetriesWithBoundedDelay_WithoutLosingCatalog() + { + int count = 0; + var waits = new System.Collections.Generic.List(); + FalModelCatalog.DelayOverrideForTests = (wait, _) => { waits.Add(wait); return Task.CompletedTask; }; + http.Handler = _ => count++ == 0 ? new HttpResult { Status = 429, RetryAfterSeconds = 999 } + : Models(Endpoint(Music)); + Assert.IsTrue(Refresh()); + Assert.AreEqual(Music, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + Assert.Contains(TimeSpan.FromSeconds(10), waits); + Assert.Contains(TimeSpan.FromSeconds(7), waits, "Public calls must use the conservative pacing interval."); + Assert.IsTrue(waits.All(wait => wait <= TimeSpan.FromSeconds(10))); + } + + [Test] + public void CorruptCache_FallsBackToBundledEntries() + { + Directory.CreateDirectory(directory); + File.WriteAllText(FalModelCatalog.CachePathOverrideForTests, "bad json"); + FalModelCatalog.ReloadCacheForTests(); + Assert.AreEqual("bundled", FalModelCatalog.Source("audio")); + Assert.AreEqual(FalAudioAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + } + + [Test] + public void MissingSavedSelection_IsPreserved_AndGenerationErrors() + { + AssetGenPrefs.SetSelectedModel("audio", "fal", "test/music-retired"); + Assert.IsTrue(Refresh()); + Assert.Throws(() => AssetGenModelCatalog.ResolveModel("audio", "fal", null)); + var root = new VisualElement(); + root.Add(new VisualElement { name = "assetgen-providers-container" }); + var section = new McpAssetGenSection(root); + Assert.AreEqual("test/music-retired", AssetGenPrefs.GetSelectedModel("audio", "fal")); + Assert.IsTrue(root.Query().ToList().Any(dropdown => dropdown.value?.Contains("Saved model unavailable") == true)); + } + + [Test] + public void NullableFractionalDuration_UsesLiveTextField_AndBounds() + { + var model = Endpoint("test/sfx-v2", prompt: "text"); + model["metadata"]["tags"] = new JArray("sfx"); + Input(model)["properties"]["duration_seconds"] = new JObject { ["anyOf"] = new JArray( + new JObject { ["type"] = "number", ["minimum"] = 0.5, ["maximum"] = 22 }, new JObject { ["type"] = "null" }) }; + var entry = FalModelSchema.Parse(model, "audio", now.ToString("O")); + Assert.IsNotNull(entry); + var submit = new FakeHttpTransport { Handler = _ => new HttpResult { Status = 200, Text = "{\"response_url\":\"https://queue.fal.run/test/sfx-v2/requests/r1\"}" } }; + new FalAudioAdapter().SubmitAsync(new AudioGenRequest { Model = entry.Id, CatalogEntry = entry, Prompt = "rain", Duration = 0.75f }, "test-key", submit, CancellationToken.None).GetAwaiter().GetResult(); + var body = JObject.Parse(Encoding.UTF8.GetString(submit.RecordedRequests[0].Body)); + Assert.AreEqual("rain", (string)body["text"]); + Assert.IsNull(body["prompt"]); + Assert.AreEqual(0.75f, (float)body["duration_seconds"]); + } + + [Test] + public void MillisecondDuration_ConvertsFromSeconds_AndClampsToSchemaMinimum() + { + var model = Endpoint(Music); + Input(model)["properties"]["music_length_ms"] = new JObject { ["type"] = "integer", ["minimum"] = 3000, ["maximum"] = 600000, ["default"] = 10000 }; + var entry = FalModelSchema.Parse(model, "audio", now.ToString("O")); + var submit = new FakeHttpTransport { Handler = _ => new HttpResult { Status = 200, Text = "{\"response_url\":\"https://queue.fal.run/test/music-v2/requests/r1\"}" } }; + new FalAudioAdapter().SubmitAsync(new AudioGenRequest { Model = entry.Id, CatalogEntry = entry, Prompt = "rain", Duration = 1f }, "test-key", submit, CancellationToken.None).GetAwaiter().GetResult(); + var body = JObject.Parse(Encoding.UTF8.GetString(submit.RecordedRequests[0].Body)); + Assert.AreEqual(3000, (int)body["music_length_ms"]); + Assert.AreEqual(JTokenType.Integer, body["music_length_ms"].Type); + } + + [Test] + public void SchemaGate_RejectsUnsupportedOutputAndUnsafeEndpointId() + { + var model = Endpoint(Music); + model["openapi"]["components"]["schemas"]["Output"]["properties"] = new JObject { ["result"] = new JObject { ["type"] = "string" } }; + Assert.IsNull(FalModelSchema.Parse(model, "audio", now.ToString("O"))); + Assert.IsNull(FalModelSchema.Parse(Endpoint("test/../music"), "audio", now.ToString("O"))); + Assert.IsNull(FalModelSchema.Parse(Endpoint("https://attacker.invalid/music"), "audio", now.ToString("O"))); + } + + [Test] + public void ImageSchema_ChoosesImportableOutputFormat_AndRejectsVectorOnlyModels() + { + var model = Endpoint("test/image", "image"); + Input(model)["properties"]["output_format"] = new JObject { ["type"] = "string", ["enum"] = new JArray("webp", "png"), ["default"] = "webp" }; + Assert.AreEqual("png", FalModelSchema.Parse(model, "image", now.ToString("O")).OutputFormat); + Input(model)["properties"]["output_format"]["enum"] = new JArray("svg"); + Assert.IsNull(FalModelSchema.Parse(model, "image", now.ToString("O"))); + Assert.IsNull(FalModelSchema.Parse(Endpoint("test/text-to-vector", "image"), "image", now.ToString("O"))); + } + + [Test] + public void ExplicitUnlistedModel_RemainsAllowed_ForExactEndpointPreflight() + { + Assert.IsTrue(Refresh()); + Assert.AreEqual("test/music-custom", AssetGenModelCatalog.ResolveModel("audio", "fal", "test/music-custom")); + var custom = Endpoint("test/music-custom"); + Serve(custom); + Assert.IsNotNull(FalModelCatalog.VerifyForGeneration("test/music-custom", "audio", "text", CancellationToken.None).GetAwaiter().GetResult()); + } + + [Test] + public void ImageRefresh_AdvertisesOnlyVerifiedEditing_AndShapesRequest() + { + const string imageId = "test/image-v2"; + Serve(Endpoint(imageId, "image"), Endpoint(imageId + "/edit", "image", edit: true)); + Assert.IsTrue(Refresh("image")); + var entry = AssetGenModelCatalog.Find(imageId); + Assert.AreEqual(imageId + "/edit", entry.EditModelId); + var submit = new FakeHttpTransport { Handler = _ => new HttpResult { Status = 200, Text = "{\"response_url\":\"https://queue.fal.run/test/image-v2/requests/r1\"}" } }; + new FalAdapter().SubmitAsync(new ImageGenRequest { Model = imageId, CatalogEntry = entry, Mode = "image", Prompt = "rain", ImageUrl = "https://example.com/image.png" }, "test-key", submit, CancellationToken.None).GetAwaiter().GetResult(); + var body = JObject.Parse(Encoding.UTF8.GetString(submit.RecordedRequests[0].Body)); + Assert.AreEqual("https://example.com/image.png", (string)body["image_url"]); + Assert.IsNull(body["image_urls"]); + Assert.IsNull(body["num_images"]); + } + + [Test] + public void ImageWithoutEditSchema_RejectsImageMode_InsteadOfGuessingEndpoint() + { + Serve(Endpoint("test/image-v2", "image")); + Assert.IsTrue(Refresh("image")); + var entry = AssetGenModelCatalog.Find("test/image-v2"); + Assert.IsNull(entry.EditModelId); + Assert.Throws(() => new FalAdapter().SubmitAsync(new ImageGenRequest + { Model = entry.Id, CatalogEntry = entry, Mode = "image", ImageUrl = "https://example.com/in.png" }, "test-key", new FakeHttpTransport(), CancellationToken.None).GetAwaiter().GetResult()); + } + + [Test] + public void MissingFindEndpoints_404_IsANegativeResult_ButList404PreservesCache() + { + http.Handler = request => request.Url.Contains("endpoint_id=") + ? new HttpResult { Status = 404, Text = "{\"error\":{\"type\":\"not_found\",\"message\":\"Endpoint(s) not found\"}}" } + : Models(Endpoint(Music)); + Assert.IsTrue(Refresh()); + Assert.IsEmpty(AssetGenModelCatalog.ForProvider("fal", "audio")); + http.Handler = _ => new HttpResult { Status = 404, Text = "{\"error\":{\"type\":\"not_found\"}}" }; + Assert.IsFalse(Refresh()); + Assert.AreEqual("live", FalModelCatalog.Source("audio")); + } + + [TestCase("tripo"), TestCase("meshy")] + public void Bundled3DModels_AdvertiseBothTextAndImageModes(string provider) + { + var response = JObject.FromObject(GenerateModel.HandleCommand(new JObject { ["action"] = "list_models", ["provider"] = provider })); + CollectionAssert.AreEqual(new[] { "text", "image" }, response["data"]["models"][0]["capabilities"].Values()); + Assert.AreEqual("unverified", (string)response["data"]["models"][0]["status"]); + } + + [Test] + public void GenerationPreflight_UnavailableModel_FailsBeforePaidSubmit() + { + string previousKey = Environment.GetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY"); + Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", "test-key"); + try + { + Serve(); + var submit = new FakeHttpTransport(); + AssetGenJobManager.TransportOverrideForTests = submit; + var job = AssetGenJobManager.StartAudioGeneration(new AudioGenRequest { Provider = "fal", Model = Music, Prompt = "rain" }); + var tick = typeof(AssetGenJobManager).GetMethod("Tick", BindingFlags.NonPublic | BindingFlags.Static); + for (int i = 0; i < 5; i++) tick.Invoke(null, null); + Assert.AreEqual(AssetGenJobState.Failed, job.State); + StringAssert.Contains("unavailable", job.Error); + Assert.IsEmpty(submit.RecordedRequests, "A failed availability check must not submit a paid generation."); + Assert.IsTrue(http.RecordedRequests.All(request => request.Headers["Authorization"] == "Key test-key" && new Uri(request.Url).Host == "api.fal.ai")); + } + finally { Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", previousKey); } + } + + private sealed class DelayedTransport : IHttpTransport + { + public readonly TaskCompletionSource First = new(); + private int count; + public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + => count++ == 0 ? First.Task : Task.FromResult(Models(Endpoint(Music))); + } + + private sealed class LiveTransport : IHttpTransport + { + public async Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + { + var result = await new UnityWebRequestTransport().SendAsync(spec, ct); + TestContext.WriteLine(spec.Url + " => " + result.Status + (result.Ok ? "" : " " + ProviderHttp.Truncate(result.Text))); + return result; + } + } + + [UnityTest] + public IEnumerator ConcurrentRefreshes_ShareOneOperation() + { + var delayed = new DelayedTransport(); + FalModelCatalog.TransportOverrideForTests = delayed; + var first = FalModelCatalog.RefreshAsync("audio", true); + var second = FalModelCatalog.RefreshAsync("audio", true); + Assert.AreSame(first, second); + Assert.IsTrue(FalModelCatalog.IsRefreshing("audio")); + delayed.First.SetResult(Models(Endpoint(Music))); + while (!first.IsCompleted) yield return null; + Assert.IsTrue(first.Result); + Assert.IsFalse(FalModelCatalog.IsRefreshing("audio")); + } + + [UnityTest] + public IEnumerator BackgroundRefresh_PreservesUnsavedApiKeyInput() + { + var delayed = new DelayedTransport(); + FalModelCatalog.TransportOverrideForTests = delayed; + var root = new VisualElement(); + root.Add(new VisualElement { name = "assetgen-providers-container" }); + var section = new McpAssetGenSection(root); + var field = root.Query().ToList().First(candidate => candidate.isPasswordField); + field.value = "unsaved-test-input"; + delayed.First.SetResult(Models()); + while (FalModelCatalog.IsRefreshing("image") || FalModelCatalog.IsRefreshing("audio")) yield return null; + yield return null; + Assert.AreEqual("unsaved-test-input", field.value); + Assert.IsTrue(root.Contains(field), "The automatic refresh must not replace key-entry controls."); + } + + [UnityTest, Explicit("Queries the public fal catalog and OpenAPI schemas; no paid generation.")] + public IEnumerator LivePublicCatalog_RefreshAndExactEndpointVerification() + { + if (Environment.GetEnvironmentVariable("MCPFORUNITY_RUN_LIVE_CATALOG") != "1") + Assert.Ignore("Set MCPFORUNITY_RUN_LIVE_CATALOG=1 to opt into public API verification."); + FalModelCatalog.TransportOverrideForTests = new LiveTransport(); + FalModelCatalog.DelayOverrideForTests = null; + FalModelCatalog.UtcNow = () => DateTime.UtcNow; + var audio = FalModelCatalog.RefreshAsync("audio", true); + var image = FalModelCatalog.RefreshAsync("image", true); + while (!audio.IsCompleted || !image.IsCompleted) yield return null; + Assert.IsTrue(audio.Result, FalModelCatalog.LastError("audio")); + Assert.IsTrue(image.Result, FalModelCatalog.LastError("image")); + foreach (string kind in new[] { "audio", "image" }) + { + var entries = AssetGenModelCatalog.ForProvider("fal", kind); + Assert.IsNotEmpty(entries); + Assert.IsTrue(entries.All(entry => entry.FromRefresh && !string.IsNullOrEmpty(entry.VerifiedAt))); + TestContext.WriteLine(kind + ": " + string.Join(", ", entries.Select(entry => entry.Id))); + var verify = FalModelCatalog.VerifyForGeneration(entries[0].Id, kind, "text", CancellationToken.None); + while (!verify.IsCompleted) yield return null; + Assert.IsFalse(verify.IsFaulted, verify.Exception?.ToString()); + Assert.IsNotNull(verify.Result); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs.meta new file mode 100644 index 000000000..8055e76f4 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52f0ad827bae435cae3e1d0109332a1b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs index 748b9c34d..e784c5577 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs @@ -19,6 +19,8 @@ public class GenerateAudioTests public void SetUp() { AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + AssetGenJobManager.SkipModelVerificationForTests = true; Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null); _dir = Path.Combine(Path.GetTempPath(), "mcp_audiohandler_" + Guid.NewGuid().ToString("N")); _store = new EncryptedFileKeyStore(_dir); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs index f75cdc96f..16eeaf407 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs @@ -23,6 +23,8 @@ public class GenerateImageTests public void SetUp() { AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + AssetGenJobManager.SkipModelVerificationForTests = true; Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null); Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", null); _dir = Path.Combine(Path.GetTempPath(), "mcp_imghandler_" + Guid.NewGuid().ToString("N")); diff --git a/docs/asset-gen-manual-verification.md b/docs/asset-gen-manual-verification.md index 9b33a0a95..70cdeb583 100644 --- a/docs/asset-gen-manual-verification.md +++ b/docs/asset-gen-manual-verification.md @@ -25,6 +25,18 @@ genuine provider keys and an interactive Editor before shipping. - [ ] `generate_model(provider=tripo, mode=text, prompt="...", format=glb)`, poll status. - [ ] Confirm the GLB imports correctly (no missing-importer error). +## Dynamic fal model catalog + +- Open Asset Generation: cached choices appear immediately, and fal image/audio catalogs refresh in the background after 24 hours. Refresh forces a fetch. Tripo, Meshy and OpenRouter remain bundled in this version. +- Run `generate_audio(action="list_models")` or `generate_image(action="list_models", provider="fal")`. The response includes `models` and `catalogs` with source, verification time, staleness, errors and refresh progress. If `refreshing` is true, query `list_models` again later. `refresh_models` forces a background fetch. CLI equivalent: `unity-mcp asset-gen list-models --kind audio --refresh`. +- Discovery uses the public [fal model search API](https://fal.ai/docs/platform-apis/v1/models), optionally attaching the locally configured fal key for higher rate limits. A complete metadata fetch is followed by OpenAPI checks for a bounded shortlist (known models, saved selection, vendor highlights, then five recent candidates). Recency is not a quality benchmark. Speech/vector endpoints, unsupported required inputs and unknown output shapes are excluded. +- Each kind commits independently after its pages/schema batches succeed. Simulate a timeout/429 or malformed response: the previous successful snapshot and its original timestamp must remain. Automatic failed fetches back off for two minutes; manual Refresh retries immediately. Successful empty snapshots must not restore removed bundled entries. +- Select an audio model using `text` instead of `prompt`, fractional seconds or milliseconds: verify the adapter uses the live field names, units and limits. Image editing is offered only when the exact `/edit` endpoint has a compatible schema. Unsupported editing and dimensions return an error. +- Save a model selection, then make it unavailable in a fake catalog: the panel must preserve the saved ID and ask for another selection; generation must not silently switch models. Automatic refresh must preserve unsaved API-key text. +- Before each fal generation, a free exact-endpoint query verifies status and captures the current schema. An unavailable/incompatible endpoint or a failed verification must fail the job before a paid submit. This does not run paid generation probes or measure output quality. +- Cache lives in the project's ignored `Library/MCPForUnity/fal-model-catalog.json`; it contains no provider credentials. Delete/corrupt the cache and reopen: bundled entries appear as unverified until discovery succeeds. +- Requests are serialized and paced, and HTTP 429 responses get bounded retries; a failed fetch still preserves the cache. For a real, unpaid integration check, set `MCPFORUNITY_RUN_LIVE_CATALOG=1` and explicitly run `FalModelCatalogTests.LivePublicCatalog_RefreshAndExactEndpointVerification` in EditMode. Regular runs exclude this network test. + ## fal.ai (default 2D image) - [ ] Enter the fal key. diff --git a/website/docs/reference/tools/asset_gen/generate_audio.md b/website/docs/reference/tools/asset_gen/generate_audio.md index ad1df7f0b..9d2d37eea 100644 --- a/website/docs/reference/tools/asset_gen/generate_audio.md +++ b/website/docs/reference/tools/asset_gen/generate_audio.md @@ -14,22 +14,24 @@ description: "Generate audio (sound effects and background music) with fal.ai mo Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in the editor's secure store (shared with image generation) and never crosses the bridge. -MODELS (all via fal.ai): fal-ai/stable-audio-25/text-to-audio (music + SFX, <=190s), cassetteai/sound-effects-generator (SFX, <=30s), cassetteai/music-generator (music), fal-ai/lyria2 (music). Omit model to use the model selected in the MCP for Unity -> Asset Generation tab. +Use list_models to discover current compatible sound/music models, their duration limits and catalog freshness. Omit model to use the model selected in the MCP for Unity -> Asset Generation tab. ACTIONS: - generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the status action. Params: provider (fal), prompt, model, duration (seconds), name, output_folder. - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured audio providers and capabilities (no key values). +- list_models: List models from the editor's shared catalog; refresh stale fal data in the background. If catalogs[].refreshing is true, call list_models again later. +- refresh_models: Force a background fal catalog refresh; returns the current snapshot. ## Parameters | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['generate', 'status', 'cancel', 'list_providers']` | yes | Action to perform. | +| `action` | `Literal['generate', 'status', 'cancel', 'list_providers', 'list_models', 'refresh_models']` | yes | Action to perform. | | `provider` | `str \| None` | — | Provider id (fal). | | `prompt` | `str \| None` | — | Text prompt describing the sound or music. | -| `model` | `str \| None` | — | fal model id (e.g. fal-ai/stable-audio-25/text-to-audio). Omit to use the GUI-selected default. | +| `model` | `str \| None` | — | fal model id returned by list_models. Omit to use the GUI-selected default. | | `duration` | `float \| None` | — | Requested length in seconds (soft-clamped per model). | | `name` | `str \| None` | — | Base name for the imported asset. | | `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. | @@ -42,6 +44,16 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -*No examples yet. Add usage examples here — they will be preserved across regenerations.* +Discover the current sound/music models: + +```json +{"action": "list_models", "provider": "fal"} +``` + +Use `refresh_models` to force a background fetch. If `data.catalogs[].refreshing` is true, +query `list_models` again later. Check `source`, `stale` and `last_verified` before choosing +an ID from `data.models`. The selected endpoint is checked again before paid generation. + +CLI: `unity-mcp asset-gen list-models --kind audio --refresh`. diff --git a/website/docs/reference/tools/asset_gen/generate_image.md b/website/docs/reference/tools/asset_gen/generate_image.md index 75eb2be94..5a0ed67c6 100644 --- a/website/docs/reference/tools/asset_gen/generate_image.md +++ b/website/docs/reference/tools/asset_gen/generate_image.md @@ -20,18 +20,20 @@ ACTIONS: - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured image providers and capabilities (no key values). +- list_models: List models and supported modes from the editor's shared catalog; refresh stale fal data in the background. If catalogs[].refreshing is true, call list_models again later. OpenRouter uses the bundled catalog. +- refresh_models: Force a background fal catalog refresh; returns the current snapshot. ## Parameters | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['generate', 'remove_background', 'status', 'cancel', 'list_providers']` | yes | Action to perform. | +| `action` | `Literal['generate', 'remove_background', 'status', 'cancel', 'list_providers', 'list_models', 'refresh_models']` | yes | Action to perform. | | `provider` | `str \| None` | — | Provider id (fal, openrouter). | | `mode` | `str \| None` | — | Generation mode: text or image. | | `prompt` | `str \| None` | — | Text prompt for text->image. | | `image_path` | `str \| None` | — | Path to a source image for image->image mode. | | `image_url` | `str \| None` | — | URL of a source image for image->image. | -| `model` | `str \| None` | — | Provider model id/slug (e.g. FLUX, gemini-2.5-flash-image). | +| `model` | `str \| None` | — | Provider model id/slug returned by list_models; omit for the GUI-selected default. | | `transparent` | `bool \| None` | — | Mark the imported texture as alpha-is-transparency. NOTE: fal/FLUX and OpenRouter have no generation-time transparency, so this only sets the Unity import flag — it does not make the model render a transparent background. | | `width` | `int \| None` | — | Output width in pixels. | | `height` | `int \| None` | — | Output height in pixels. | @@ -46,6 +48,15 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -*No examples yet. Add usage examples here — they will be preserved across regenerations.* +```json +{"action": "list_models", "provider": "fal"} +``` + +Use an ID from `data.models` and inspect its `capabilities` before choosing text or image +mode. Image editing requires a verified editing endpoint. `refresh_models` forces a +background fal refresh; repeat `list_models` when `data.catalogs[].refreshing` becomes false. +OpenRouter currently returns bundled entries marked unverified. + +CLI: `unity-mcp asset-gen list-models --kind image --provider fal --refresh`. diff --git a/website/docs/reference/tools/asset_gen/generate_model.md b/website/docs/reference/tools/asset_gen/generate_model.md index a5a5da4fa..8cc033f11 100644 --- a/website/docs/reference/tools/asset_gen/generate_model.md +++ b/website/docs/reference/tools/asset_gen/generate_model.md @@ -19,12 +19,13 @@ ACTIONS: - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured 3D providers and capabilities (no key values). +- list_models: List bundled 3D models. Live discovery is currently limited to fal image/audio; 3D entries are marked unverified. ## Parameters | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['generate', 'status', 'cancel', 'list_providers']` | yes | Action to perform. | +| `action` | `Literal['generate', 'status', 'cancel', 'list_providers', 'list_models']` | yes | Action to perform. | | `provider` | `str \| None` | — | Provider id (tripo, meshy). | | `mode` | `str \| None` | — | Generation mode: text or image. | | `prompt` | `str \| None` | — | Text prompt for text->3D. | @@ -46,6 +47,13 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -*No examples yet. Add usage examples here — they will be preserved across regenerations.* +```json +{"action": "list_models", "provider": "meshy"} +``` + +This version returns bundled Tripo/Meshy entries marked unverified. Live discovery is +currently available for fal image/audio models. + +CLI: `unity-mcp asset-gen list-models --kind model`. From e34b2c8a58dfff96b5e44b6cfeddf6f7b01c8e87 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:13:01 -0400 Subject: [PATCH 2/7] docs: shorten asset model catalog guidance --- Server/src/services/tools/generate_audio.py | 10 ++++------ Server/src/services/tools/generate_image.py | 6 ++---- Server/src/services/tools/generate_model.py | 3 +-- docs/asset-gen-manual-verification.md | 13 ++++--------- .../reference/tools/asset_gen/generate_audio.md | 12 +++--------- .../reference/tools/asset_gen/generate_image.md | 9 ++------- .../reference/tools/asset_gen/generate_model.md | 5 +---- 7 files changed, 17 insertions(+), 41 deletions(-) diff --git a/Server/src/services/tools/generate_audio.py b/Server/src/services/tools/generate_audio.py index 119529616..87421acaf 100644 --- a/Server/src/services/tools/generate_audio.py +++ b/Server/src/services/tools/generate_audio.py @@ -22,9 +22,8 @@ "Generate audio (sound effects and background music) with fal.ai models and import " "them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in " "the editor's secure store (shared with image generation) and never crosses the bridge.\n\n" - "Use list_models to discover current compatible sound/music models, their duration " - "limits and catalog freshness. Omit model to use the model selected in the " - "MCP for Unity -> Asset Generation tab.\n\n" + "Use list_models for current compatible models. Omit model to use the " + "Asset Generation tab selection.\n\n" "ACTIONS:\n" "- generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the " "status action. Params: provider (fal), prompt, model, duration (seconds), name, " @@ -32,9 +31,8 @@ "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured audio providers and capabilities (no key values).\n" - "- list_models: List models from the editor's shared catalog; refresh stale fal data " - "in the background. If catalogs[].refreshing is true, call list_models again later.\n" - "- refresh_models: Force a background fal catalog refresh; returns the current snapshot." + "- list_models: List models and freshness; refresh stale fal data in the background.\n" + "- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true." ), annotations=ToolAnnotations( title="Generate Audio", diff --git a/Server/src/services/tools/generate_image.py b/Server/src/services/tools/generate_image.py index 0f013786c..21bfbd964 100644 --- a/Server/src/services/tools/generate_image.py +++ b/Server/src/services/tools/generate_image.py @@ -31,10 +31,8 @@ "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured image providers and capabilities (no key values).\n" - "- list_models: List models and supported modes from the editor's shared catalog; " - "refresh stale fal data in the background. If catalogs[].refreshing is true, call " - "list_models again later. OpenRouter uses the bundled catalog.\n" - "- refresh_models: Force a background fal catalog refresh; returns the current snapshot." + "- list_models: List models, modes and freshness. OpenRouter uses bundled models.\n" + "- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true." ), annotations=ToolAnnotations( title="Generate Image", diff --git a/Server/src/services/tools/generate_model.py b/Server/src/services/tools/generate_model.py index e2e32d39c..3dd5e9f1e 100644 --- a/Server/src/services/tools/generate_model.py +++ b/Server/src/services/tools/generate_model.py @@ -30,8 +30,7 @@ "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured 3D providers and capabilities (no key values).\n" - "- list_models: List bundled 3D models. Live discovery is currently limited to " - "fal image/audio; 3D entries are marked unverified." + "- list_models: List bundled Tripo/Meshy models (unverified)." ), annotations=ToolAnnotations( title="Generate Model", diff --git a/docs/asset-gen-manual-verification.md b/docs/asset-gen-manual-verification.md index 70cdeb583..f6aad0ffa 100644 --- a/docs/asset-gen-manual-verification.md +++ b/docs/asset-gen-manual-verification.md @@ -27,15 +27,10 @@ genuine provider keys and an interactive Editor before shipping. ## Dynamic fal model catalog -- Open Asset Generation: cached choices appear immediately, and fal image/audio catalogs refresh in the background after 24 hours. Refresh forces a fetch. Tripo, Meshy and OpenRouter remain bundled in this version. -- Run `generate_audio(action="list_models")` or `generate_image(action="list_models", provider="fal")`. The response includes `models` and `catalogs` with source, verification time, staleness, errors and refresh progress. If `refreshing` is true, query `list_models` again later. `refresh_models` forces a background fetch. CLI equivalent: `unity-mcp asset-gen list-models --kind audio --refresh`. -- Discovery uses the public [fal model search API](https://fal.ai/docs/platform-apis/v1/models), optionally attaching the locally configured fal key for higher rate limits. A complete metadata fetch is followed by OpenAPI checks for a bounded shortlist (known models, saved selection, vendor highlights, then five recent candidates). Recency is not a quality benchmark. Speech/vector endpoints, unsupported required inputs and unknown output shapes are excluded. -- Each kind commits independently after its pages/schema batches succeed. Simulate a timeout/429 or malformed response: the previous successful snapshot and its original timestamp must remain. Automatic failed fetches back off for two minutes; manual Refresh retries immediately. Successful empty snapshots must not restore removed bundled entries. -- Select an audio model using `text` instead of `prompt`, fractional seconds or milliseconds: verify the adapter uses the live field names, units and limits. Image editing is offered only when the exact `/edit` endpoint has a compatible schema. Unsupported editing and dimensions return an error. -- Save a model selection, then make it unavailable in a fake catalog: the panel must preserve the saved ID and ask for another selection; generation must not silently switch models. Automatic refresh must preserve unsaved API-key text. -- Before each fal generation, a free exact-endpoint query verifies status and captures the current schema. An unavailable/incompatible endpoint or a failed verification must fail the job before a paid submit. This does not run paid generation probes or measure output quality. -- Cache lives in the project's ignored `Library/MCPForUnity/fal-model-catalog.json`; it contains no provider credentials. Delete/corrupt the cache and reopen: bundled entries appear as unverified until discovery succeeds. -- Requests are serialized and paced, and HTTP 429 responses get bounded retries; a failed fetch still preserves the cache. For a real, unpaid integration check, set `MCPFORUNITY_RUN_LIVE_CATALOG=1` and explicitly run `FalModelCatalogTests.LivePublicCatalog_RefreshAndExactEndpointVerification` in EditMode. Regular runs exclude this network test. +- [ ] Open Asset Generation and click **Refresh**: fal image/audio choices update. Other providers remain bundled and unverified. +- [ ] Run `unity-mcp asset-gen list-models --kind audio --refresh`; repeat without `--refresh` while `catalogs[].refreshing` is true. Check source and verification time. +- [ ] Confirm failed refreshes preserve cached choices, missing models preserve the saved ID, and background refreshes preserve unsaved key input. +- [ ] Set `MCPFORUNITY_RUN_LIVE_CATALOG=1` and run `FalModelCatalogTests.LivePublicCatalog_RefreshAndExactEndpointVerification` in EditMode. It checks the live catalog without paid generation. ## fal.ai (default 2D image) diff --git a/website/docs/reference/tools/asset_gen/generate_audio.md b/website/docs/reference/tools/asset_gen/generate_audio.md index 9d2d37eea..0d815545c 100644 --- a/website/docs/reference/tools/asset_gen/generate_audio.md +++ b/website/docs/reference/tools/asset_gen/generate_audio.md @@ -14,15 +14,15 @@ description: "Generate audio (sound effects and background music) with fal.ai mo Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in the editor's secure store (shared with image generation) and never crosses the bridge. -Use list_models to discover current compatible sound/music models, their duration limits and catalog freshness. Omit model to use the model selected in the MCP for Unity -> Asset Generation tab. +Use list_models for current compatible models. Omit model to use the Asset Generation tab selection. ACTIONS: - generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the status action. Params: provider (fal), prompt, model, duration (seconds), name, output_folder. - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured audio providers and capabilities (no key values). -- list_models: List models from the editor's shared catalog; refresh stale fal data in the background. If catalogs[].refreshing is true, call list_models again later. -- refresh_models: Force a background fal catalog refresh; returns the current snapshot. +- list_models: List models and freshness; refresh stale fal data in the background. +- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true. ## Parameters @@ -44,16 +44,10 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -Discover the current sound/music models: - ```json {"action": "list_models", "provider": "fal"} ``` -Use `refresh_models` to force a background fetch. If `data.catalogs[].refreshing` is true, -query `list_models` again later. Check `source`, `stale` and `last_verified` before choosing -an ID from `data.models`. The selected endpoint is checked again before paid generation. - CLI: `unity-mcp asset-gen list-models --kind audio --refresh`. diff --git a/website/docs/reference/tools/asset_gen/generate_image.md b/website/docs/reference/tools/asset_gen/generate_image.md index 5a0ed67c6..59fbf17b7 100644 --- a/website/docs/reference/tools/asset_gen/generate_image.md +++ b/website/docs/reference/tools/asset_gen/generate_image.md @@ -20,8 +20,8 @@ ACTIONS: - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured image providers and capabilities (no key values). -- list_models: List models and supported modes from the editor's shared catalog; refresh stale fal data in the background. If catalogs[].refreshing is true, call list_models again later. OpenRouter uses the bundled catalog. -- refresh_models: Force a background fal catalog refresh; returns the current snapshot. +- list_models: List models, modes and freshness. OpenRouter uses bundled models. +- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true. ## Parameters @@ -52,11 +52,6 @@ A `dict` containing the Unity response. The exact shape depends on the action. {"action": "list_models", "provider": "fal"} ``` -Use an ID from `data.models` and inspect its `capabilities` before choosing text or image -mode. Image editing requires a verified editing endpoint. `refresh_models` forces a -background fal refresh; repeat `list_models` when `data.catalogs[].refreshing` becomes false. -OpenRouter currently returns bundled entries marked unverified. - CLI: `unity-mcp asset-gen list-models --kind image --provider fal --refresh`. diff --git a/website/docs/reference/tools/asset_gen/generate_model.md b/website/docs/reference/tools/asset_gen/generate_model.md index 8cc033f11..69701045e 100644 --- a/website/docs/reference/tools/asset_gen/generate_model.md +++ b/website/docs/reference/tools/asset_gen/generate_model.md @@ -19,7 +19,7 @@ ACTIONS: - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured 3D providers and capabilities (no key values). -- list_models: List bundled 3D models. Live discovery is currently limited to fal image/audio; 3D entries are marked unverified. +- list_models: List bundled Tripo/Meshy models (unverified). ## Parameters @@ -51,9 +51,6 @@ A `dict` containing the Unity response. The exact shape depends on the action. {"action": "list_models", "provider": "meshy"} ``` -This version returns bundled Tripo/Meshy entries marked unverified. Live discovery is -currently available for fal image/audio models. - CLI: `unity-mcp asset-gen list-models --kind model`. From 2b8d046f61994a7b4a68ff33bf0f91570ee3c183 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:38:35 -0400 Subject: [PATCH 3/7] Discover complete fal and OpenRouter catalogs with on-demand compatibility checks --- .../Services/AssetGen/AssetGenJobManager.cs | 32 ++- .../Services/AssetGen/AssetGenModelCatalog.cs | 29 ++- .../Services/AssetGen/FalModelCatalog.cs | 91 ++++++--- .../Services/AssetGen/FalModelSchema.cs | 68 ++++-- .../Services/AssetGen/ImageResultFormat.cs | 36 ++++ .../AssetGen/ImageResultFormat.cs.meta | 11 + .../AssetGen/OpenRouterModelCatalog.cs | 193 ++++++++++++++++++ .../AssetGen/OpenRouterModelCatalog.cs.meta | 11 + .../AssetGen/Providers/AssetGenProviders.cs | 5 +- .../Services/AssetGen/Providers/FalAdapter.cs | 31 ++- .../AssetGen/Providers/FalModelAdapter.cs | 41 ++++ .../Providers/FalModelAdapter.cs.meta | 11 + .../AssetGen/Providers/OpenRouterAdapter.cs | 39 +++- .../AssetGen/Providers/ProviderModels.cs | 1 + .../Tools/AssetGen/AssetGenToolHelpers.cs | 37 ++-- .../Editor/Tools/AssetGen/GenerateModel.cs | 3 +- .../Components/AssetGen/McpAssetGenSection.cs | 80 ++++++-- Server/src/cli/commands/asset_gen.py | 17 +- Server/src/services/tools/generate_audio.py | 10 +- Server/src/services/tools/generate_image.py | 10 +- Server/src/services/tools/generate_model.py | 15 +- Server/tests/test_asset_gen_catalog.py | 29 ++- .../AssetGen/AssetGenJobManagerTests.cs | 24 +++ .../EditMode/AssetGen/FalModelCatalogTests.cs | 114 ++++++++++- .../AssetGen/OpenRouterModelCatalogTests.cs | 181 ++++++++++++++++ .../OpenRouterModelCatalogTests.cs.meta | 11 + docs/asset-gen-manual-verification.md | 8 +- .../tools/asset_gen/generate_audio.md | 6 +- .../tools/asset_gen/generate_image.md | 11 +- .../tools/asset_gen/generate_model.md | 18 +- .../docs/reference/tools/asset_gen/index.md | 2 +- website/docs/reference/tools/index.md | 2 +- 32 files changed, 1043 insertions(+), 134 deletions(-) create mode 100644 MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs.meta diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index cfccf8b5a..bb4b30515 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -103,7 +103,15 @@ public static AssetGenJob StartModelGeneration(ModelGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (string.Equals(provider, "fal", StringComparison.OrdinalIgnoreCase) && !SkipModelVerificationForTests) + { + req.Model = AssetGenModelCatalog.ResolveModel("model", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "model", req.Mode, ct, apiKey); + } + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, Transport = transport, @@ -136,7 +144,15 @@ public static AssetGenJob StartImageGeneration(ImageGenRequest req) SubmitFn = async ct => { if (!SkipModelVerificationForTests && string.Equals(provider, "fal", StringComparison.OrdinalIgnoreCase)) - req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(string.IsNullOrEmpty(req.Model) ? FalAdapter.DefaultModel : req.Model, "image", req.Mode, ct, apiKey); + { + req.Model = AssetGenModelCatalog.ResolveModel("image", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "image", req.Mode, ct, apiKey); + } + if (!SkipModelVerificationForTests && string.Equals(provider, "openrouter", StringComparison.OrdinalIgnoreCase)) + { + req.Model = AssetGenModelCatalog.ResolveModel("image", provider, req.Model); + req.CatalogEntry = await OpenRouterModelCatalog.VerifyForGeneration(req.Model, req.Mode, ct); + } return await adapter.SubmitAsync(req, apiKey, transport, ct); }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), @@ -169,7 +185,10 @@ public static AssetGenJob StartAudioGeneration(AudioGenRequest req) SubmitFn = async ct => { if (!SkipModelVerificationForTests) - req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(string.IsNullOrEmpty(req.Model) ? FalAudioAdapter.DefaultModel : req.Model, "audio", "text", ct, apiKey); + { + req.Model = AssetGenModelCatalog.ResolveModel("audio", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "audio", "text", ct, apiKey); + } return await adapter.SubmitAsync(req, apiKey, transport, ct); }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), @@ -493,6 +512,13 @@ private static string WriteFile(Runner r, byte[] bytes) string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant(); if (!IsAllowedResultExtension(r.Job.Kind, ext)) throw new Exception($"provider returned a disallowed file type '.{ext}'"); + if (r.Job.Kind == "image") + { + string actual = ImageResultFormat.FromBytes(bytes); + if (actual == "webp") throw new Exception("Provider returned WebP, which this Unity image importer does not support. Choose a PNG/JPEG model."); + if (actual != null) ext = actual; + r.Job.Format = ext; + } string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder : (AssetGenPrefs.OutputRoot + "/" + r.Subfolder); if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root)) diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs index c2616c3a6..5e7f33cb1 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -46,12 +46,16 @@ public sealed class ModelEntry public string VerifiedAt; public string OutputFormat; public string EditOutputFormat; + public string[] Modes; + public string ModelOutputField; + public string TextureField; + public string RouterProviderTag; + public Newtonsoft.Json.Linq.JObject RouterParameters; } /// - /// Shared model registry for the panel and tools. A successfully refreshed fal snapshot - /// replaces its bundled entries, including removals. Bundled entries bootstrap first use; - /// other providers remain bundled until their discovery adapters are implemented. + /// Shared registry for the panel and tools. Live fal and OpenRouter snapshots replace + /// bundled entries, including removals. Discovered entries are verified before generation. /// public static class AssetGenModelCatalog { @@ -69,6 +73,7 @@ public static class AssetGenModelCatalog new ModelEntry { Id = TripoAdapter.ModelVersion, Label = "Tripo v3.1", Provider = "tripo", Kind = "model", UseCase = "Text / image -> 3D" }, new ModelEntry { Id = "P1-20260311", Label = "Tripo P1 (premium)", Provider = "tripo", Kind = "model", UseCase = "Premium 3D" }, new ModelEntry { Id = MeshyAdapter.DefaultModel, Label = "Meshy 6", Provider = "meshy", Kind = "model", UseCase = "Text / image -> 3D" }, + new ModelEntry { Id = FalModelAdapter.DefaultModel, Label = "Hunyuan3D", Provider = "fal", Kind = "model", UseCase = "Text -> 3D", Modes = new[] { "text" } }, // Audio — fal (order: stable-audio, cassette SFX, cassette music, lyria). DurationField // is the request key each endpoint expects; null (Lyria) => prompt-only, no duration knob. @@ -89,6 +94,7 @@ internal static IReadOnlyList Bundled(string provider, string kind) public static IReadOnlyList ForProvider(string provider, string kind) { if (Eq(provider, "fal") && FalModelCatalog.TryGet(kind, out var entries)) return entries; + if (Eq(provider, "openrouter") && Eq(kind, "image") && OpenRouterModelCatalog.TryGet(out var images)) return images; return Bundled(provider, kind); } @@ -106,13 +112,13 @@ public static ModelEntry Find(string id) /// The first current entry for a provider+kind, or null. public static string DefaultModelId(string provider, string kind) { - return ForProvider(provider, kind).FirstOrDefault()?.Id; + return ForProvider(provider, kind).FirstOrDefault(e => e.Modes == null || e.Modes.Contains("text"))?.Id; } /// /// The model id a generate_* tool should use: an explicit wins, /// else the GUI-selected model for this (kind, provider), else the catalog default. Missing - /// saved selections in a refreshed fal catalog are rejected; explicit IDs are verified at submit. + /// saved selections missing from a live catalog are rejected; explicit IDs are verified at submit. /// Single home for the /// empty -> GUI-selected -> catalog-default precedence shared by all three generate tools. /// @@ -121,13 +127,20 @@ public static string ResolveModel(string kind, string provider, string requested string model = requested; if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel(kind, provider); if (string.IsNullOrWhiteSpace(model)) model = DefaultModelId(provider, kind); - if (Eq(provider, "fal") && string.IsNullOrWhiteSpace(requested) && FalModelCatalog.TryGet(kind, out var entries) + bool authoritative = Eq(provider, "fal") && FalModelCatalog.Source(kind) != "bundled" + || Eq(provider, "openrouter") && kind == "image" && OpenRouterModelCatalog.Source != "bundled"; + var entries = ForProvider(provider, kind); + if (authoritative && string.IsNullOrWhiteSpace(requested) && (string.IsNullOrWhiteSpace(model) || !entries.Any(e => Eq(e.Id, model)))) - throw new InvalidOperationException($"Model '{model}' is not in the verified {kind} catalog. Refresh models and choose an available model; your saved selection has been preserved."); + throw new InvalidOperationException($"Model '{model}' is not in the current {kind} catalog. Refresh models and choose an available model; your saved selection has been preserved."); return string.IsNullOrWhiteSpace(model) ? null : model; } - internal static void ResetForTests(bool isolate = false) => FalModelCatalog.ResetForTests(isolate); + internal static void ResetForTests(bool isolate = false) + { + FalModelCatalog.ResetForTests(isolate); + OpenRouterModelCatalog.ResetForTests(isolate); + } private static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs index 058239f83..f7fc9d44d 100644 --- a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs @@ -16,14 +16,14 @@ namespace MCPForUnity.Editor.Services.AssetGen { /// /// Public fal discovery, optionally using a configured key for higher rate limits. Each kind - /// is committed only after all metadata pages and shortlisted schemas succeed. + /// is committed only after all metadata pages and the eager compatibility checks succeed. /// Failures preserve the last successful snapshot. /// Invoke on the editor thread; UnityWebRequest and its continuations stay on that thread. /// public static class FalModelCatalog { private const string ApiUrl = "https://api.fal.ai/v1/models"; - private const int CandidateLimit = 5; + private const int EagerVerificationLimit = 5; private static readonly TimeSpan Lifetime = TimeSpan.FromHours(24); private static readonly Dictionary Snapshots = new(); private static readonly Dictionary> Refreshes = new(); @@ -49,7 +49,7 @@ private sealed class Snapshot private sealed class Cache { - public int Version = 1; + public int Version = 2; public List Snapshots; } @@ -89,7 +89,7 @@ internal static bool TryGet(string kind, out IReadOnlyList entries) /// Coalesces concurrent refreshes. Automatic retries back off for two minutes. public static Task RefreshAsync(string kind, bool force = false) { - if (kind != "audio" && kind != "image") throw new ArgumentException("fal discovery supports audio and image.", nameof(kind)); + if (kind != "audio" && kind != "image" && kind != "model") throw new ArgumentException("Unknown asset kind.", nameof(kind)); Load(); if (IsRefreshing(kind)) return Refreshes[kind]; if (!force && !IsStale(kind)) return Task.FromResult(true); @@ -109,31 +109,36 @@ private static async Task RefreshCore(string kind) // Public discovery works without credentials; a configured key grants higher limits. if (TransportOverrideForTests == null) try { SecureKeyStore.Current.TryGet("fal", out apiKey); } catch { /* public fallback */ } - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); var http = TransportOverrideForTests ?? new UnityWebRequestTransport(); var all = await ListMetadata(kind, http, timeout.Token, apiKey); var bundled = AssetGenModelCatalog.Bundled("fal", kind); var preferred = bundled.Select(e => e.Id).ToList(); string selected = AssetGenPrefs.GetSelectedModel(kind, "fal"); if (FalModelSchema.SafeId(selected)) preferred.Insert(0, selected); - // Known profiles and user selections first; vendor highlights and recency discover - // new candidates. Recency is not presented as a measured quality score. + // Known profiles and user selections first; vendor highlights and recency order + // candidates. Recency is not presented as a measured quality score. var candidates = all.Values .Where(model => FalModelSchema.IsCandidate(model, kind)) .OrderBy(model => preferred.Contains((string)model["endpoint_id"]) ? preferred.IndexOf((string)model["endpoint_id"]) : int.MaxValue) .ThenByDescending(model => (bool?)model["metadata"]?["highlighted"] == true || (bool?)model["metadata"]?["pinned"] == true) .ThenByDescending(model => (string)model["metadata"]?["updated_at"] ?? (string)model["metadata"]?["date"]) .ThenBy(model => (string)model["endpoint_id"], StringComparer.Ordinal) - .Take(CandidateLimit + preferred.Count).ToArray(); - var ids = candidates.Select(model => (string)model["endpoint_id"]).ToList(); - if (kind == "image") ids.AddRange(ids.Select(id => id + "/edit").ToArray()); + .ToArray(); + var eager = candidates.Take(EagerVerificationLimit + preferred.Count).ToArray(); + var ids = eager.Select(model => (string)model["endpoint_id"]).ToList(); + if (kind == "image") ids.AddRange(eager.Where(m => (string)m["metadata"]?["category"] == "text-to-image") + .Select(m => (string)m["endpoint_id"] + "/edit").Where(all.ContainsKey)); var details = await Details(ids, http, timeout.Token, apiKey); string checkedAt = UtcNow().ToString("O"); var entries = new List(); foreach (var candidate in candidates) { string id = (string)candidate["endpoint_id"]; - if (!details.TryGetValue(id, out var model)) continue; // removed between list and find + // Keep the complete lightweight catalog. Expand only a small recommended + // set; all other endpoints are checked on selection or before generation. + if (!eager.Contains(candidate)) { entries.Add(FalModelSchema.Discover(candidate, kind)); continue; } + if (!details.TryGetValue(id, out var model)) continue; var entry = FalModelSchema.Parse(model, kind, checkedAt); if (entry == null) continue; if (kind == "image" && details.TryGetValue(id + "/edit", out var editModel)) @@ -146,6 +151,7 @@ private static async Task RefreshCore(string kind) entry.ImageInputIsArray = edit.ImageInputIsArray; entry.EditSupportsNumImages = edit.SupportsNumImages; entry.EditOutputFormat = edit.OutputFormat; + entry.Modes = new[] { "text", "image" }; } } entries.Add(entry); @@ -169,23 +175,30 @@ private static async Task RefreshCore(string kind) private static async Task> ListMetadata(string kind, IHttpTransport http, CancellationToken ct, string apiKey = null) { var models = new Dictionary(StringComparer.Ordinal); - var cursors = new HashSet(); - string cursor = null; - for (int page = 0; page < 50; page++) + var categories = kind == "audio" ? new[] { "text-to-audio" } + : kind == "model" ? new[] { "text-to-3d", "image-to-3d" } : new[] { "text-to-image", "image-to-image" }; + foreach (string category in categories) { - string url = ApiUrl + "?category=" + (kind == "audio" ? "text-to-audio" : "text-to-image") + "&status=active&limit=100"; - if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); - var json = await Get(url, http, ct, apiKey); - foreach (JObject model in (JArray)json["models"]) + var cursors = new HashSet(); + string cursor = null; + for (int page = 0; page < 50; page++) { - string id = (string)model["endpoint_id"]; - if (FalModelSchema.SafeId(id) && (string)model["metadata"]?["status"] == "active") models[id] = model; + string url = ApiUrl + "?category=" + category + "&status=active&limit=100"; + if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); + var json = await Get(url, http, ct, apiKey); + foreach (JObject model in (JArray)json["models"]) + { + string id = (string)model["endpoint_id"]; + if (FalModelSchema.SafeId(id) && (string)model["metadata"]?["status"] == "active" + && (string)model["metadata"]?["category"] == category) models[id] = model; + } + if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) break; + cursor = (string)json["next_cursor"]; + if (string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) throw new InvalidOperationException("Incomplete model pagination. Previous catalog retained."); + if (page == 49) throw new InvalidOperationException("Model pagination exceeded its limit. Previous catalog retained."); } - if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) return models; - cursor = (string)json["next_cursor"]; - if (string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) throw new InvalidOperationException("Incomplete model pagination. Previous catalog retained."); } - throw new InvalidOperationException("Model pagination exceeded its limit. Previous catalog retained."); + return models; } private static async Task> Details(IEnumerable ids, IHttpTransport http, CancellationToken ct, string apiKey = null) @@ -196,6 +209,7 @@ private static async Task> Details(IEnumerable "endpoint_id=" + Uri.EscapeDataString(id))); + var batch = wanted.Skip(offset).Take(5).ToArray(); string cursor = null; var cursors = new HashSet(); for (int page = 0; page < 10; page++) @@ -203,6 +217,14 @@ private static async Task> Details(IEnumerable 1 && cursor == null) + { + foreach (string id in batch) + foreach (var item in await Details(new[] { id }, http, ct, apiKey)) result[item.Key] = item.Value; + break; + } foreach (JObject model in (JArray)json["models"]) { string id = (string)model["endpoint_id"]; @@ -268,13 +290,15 @@ internal static async Task VerifyForGeneration(string id, string kin { if (!FalModelSchema.SafeId(id)) throw new InvalidOperationException("Invalid fal model ID."); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeout.CancelAfter(TimeSpan.FromSeconds(20)); - var ids = kind == "image" && mode == "image" ? new[] { id, id + "/edit" } : new[] { id }; + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + var known = AssetGenModelCatalog.ForProvider("fal", kind).FirstOrDefault(e => e.Id == id); + bool directImage = known?.Modes?.SequenceEqual(new[] { "image" }) == true; + var ids = kind == "image" && mode == "image" && !directImage ? new[] { id, id + "/edit" } : new[] { id }; var models = await Details(ids, TransportOverrideForTests ?? new UnityWebRequestTransport(), timeout.Token, apiKey); if (!models.TryGetValue(id, out var model)) throw new InvalidOperationException($"Model '{id}' is unavailable. Refresh models and choose another model."); var entry = FalModelSchema.Parse(model, kind, UtcNow().ToString("O")); if (entry == null) throw new InvalidOperationException($"Model '{id}' is unavailable or incompatible with this tool. Refresh models and choose another model."); - if (kind == "image" && mode == "image") + if (kind == "image" && mode == "image" && !entry.Modes.Contains("image")) { var edit = models.TryGetValue(id + "/edit", out var editModel) ? FalModelSchema.Parse(editModel, "image", entry.VerifiedAt, edit: true) : null; if (edit == null) throw new InvalidOperationException($"Model '{id}' has no compatible image editing endpoint."); @@ -283,6 +307,15 @@ internal static async Task VerifyForGeneration(string id, string kin entry.ImageInputIsArray = edit.ImageInputIsArray; entry.EditSupportsNumImages = edit.SupportsNumImages; entry.EditOutputFormat = edit.OutputFormat; + entry.Modes = new[] { "text", "image" }; + } + if (!entry.Modes.Contains(string.IsNullOrEmpty(mode) ? "text" : mode)) + throw new InvalidOperationException($"Model '{id}' does not support '{mode}' mode."); + Load(); + if (Snapshots.TryGetValue(kind, out var snapshot)) + { + int index = snapshot.Entries.FindIndex(e => e.Id == id); + if (index >= 0) { snapshot.Entries[index] = entry; Save(); Changed?.Invoke(kind); } } return entry; } @@ -297,10 +330,10 @@ private static void Load() { if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > 2 * 1024 * 1024) return; var cache = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); - if (cache?.Version != 1 || cache.Snapshots == null) return; + if (cache?.Version != 2 || cache.Snapshots == null) return; foreach (var snapshot in cache.Snapshots) { - if ((snapshot.Kind != "audio" && snapshot.Kind != "image") || snapshot.Entries == null + if ((snapshot.Kind != "audio" && snapshot.Kind != "image" && snapshot.Kind != "model") || snapshot.Entries == null || snapshot.CheckedAt.Kind != DateTimeKind.Utc || snapshot.CheckedAt > UtcNow().AddMinutes(5) || snapshot.Entries.Any(e => e == null || e.Provider != "fal" || e.Kind != snapshot.Kind || !FalModelSchema.SafeId(e.Id))) continue; snapshot.FromDisk = true; diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs index dc593f882..dc5bd35df 100644 --- a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs @@ -8,7 +8,7 @@ namespace MCPForUnity.Editor.Services.AssetGen /// /// Conservative compatibility gate for fal's queue OpenAPI schemas. Only local references /// and the request/response shapes understood by our adapters are accepted. An unknown - /// schema stays out of the selectable catalog instead of being guessed from a model name. + /// schema cannot pass preflight; metadata discovery alone never authorizes a paid submit. /// internal static class FalModelSchema { @@ -20,7 +20,9 @@ internal static bool SafeId(string id) internal static bool IsCandidate(JObject model, string kind) { if ((string)model?["metadata"]?["status"] != "active") return false; - if (kind != "audio") return (string)model?["metadata"]?["category"] == "text-to-image" + string category = (string)model?["metadata"]?["category"]; + if (kind == "model") return category == "text-to-3d" || category == "image-to-3d"; + if (kind == "image") return (category == "text-to-image" || category == "image-to-image") && !((string)model?["endpoint_id"] ?? "").Contains("text-to-vector"); return (string)model?["metadata"]?["category"] == "text-to-audio" && AudioUseCase(model) != null; } @@ -32,7 +34,22 @@ private static string AudioUseCase(JObject model) if (signals.Contains("stable-audio")) return "Music + SFX"; if (signals.Contains("sound-effect") || signals.Contains("sfx")) return "Sound effects"; if (signals.Contains("music") || signals.Contains("lyria")) return "Background music"; - return null; + return "Audio"; + } + + internal static ModelEntry Discover(JObject model, string kind) + { + if (!IsCandidate(model, kind) || !SafeId((string)model["endpoint_id"])) return null; + string id = (string)model["endpoint_id"]; + var metadata = model["metadata"]; + return new ModelEntry + { + Id = id, Provider = "fal", Kind = kind, Label = (string)metadata["display_name"] ?? id, + UseCase = kind == "audio" ? AudioUseCase(model) : (string)metadata["category"], + FromRefresh = true, Modes = new[] { ((string)metadata["category"]).StartsWith("image-") ? "image" : "text" }, + LicenseType = (string)metadata["license_type"], ModelUrl = "https://fal.ai/models/" + id, + CommercialNote = "Review the model's license and provider terms before commercial use.", + }; } internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, bool edit = false) @@ -41,8 +58,8 @@ internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, var metadata = model?["metadata"] as JObject; if (!SafeId(id) || (string)metadata?["status"] != "active") return null; string category = (string)metadata?["category"]; - if (category != (edit ? "image-to-image" : kind == "audio" ? "text-to-audio" : "text-to-image")) return null; - if (!edit && !IsCandidate(model, kind)) return null; + if (!IsCandidate(model, kind) || edit && category != "image-to-image") return null; + bool imageInputRequired = category.StartsWith("image-", StringComparison.Ordinal); var api = model["openapi"] as JObject; var paths = api?["paths"] as JObject; @@ -57,7 +74,7 @@ internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, if (properties == null || input["allOf"] != null || input["oneOf"] != null || input["anyOf"] != null) return null; string promptField = properties["prompt"] != null ? "prompt" : kind == "audio" && properties["text"] != null ? "text" : null; - if (promptField == null || !IsString(api, properties[promptField])) return null; + if (promptField == null && !imageInputRequired || promptField != null && !IsString(api, properties[promptField])) return null; var entry = new ModelEntry { @@ -67,6 +84,7 @@ internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, LicenseType = (string)metadata["license_type"], ModelUrl = "https://fal.ai/models/" + id, CommercialNote = "Review the model's license and provider terms before commercial use.", + Modes = new[] { imageInputRequired ? "image" : "text" }, SupportsNumImages = (string)Resolve(api, properties["num_images"])?["type"] == "integer" && AcceptsOne(Resolve(api, properties["num_images"])), SupportsImageSize = SupportsDimensions(api, properties["image_size"]), @@ -100,6 +118,14 @@ internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, break; } } + else if (kind == "model") + { + entry.UseCase = imageInputRequired ? "Image -> 3D" : "Text -> 3D"; + entry.ModelOutputField = HasFile(api, output, "model_glb") ? "model_glb" + : HasFile(api, Resolve(api, output?["properties"]?["model_urls"]), "glb") ? "model_urls.glb" : null; + if (entry.ModelOutputField == null) return null; + if ((string)Resolve(api, properties["texture"])?["type"] == "boolean") entry.TextureField = "texture"; + } else { entry.UseCase = edit ? "Image editing" : "General image"; @@ -115,21 +141,28 @@ internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, bool imageArray = (string)images?["type"] == "array" && (string)Resolve(api, Resolve(api, images["items"])?["properties"]?["url"])?["type"] == "string"; if (!imageArray && !HasFile(api, output, "image")) return null; - if (edit) + } + if (imageInputRequired) + { + string field = properties["image_urls"] != null ? "image_urls" : properties["image_url"] != null ? "image_url" : null; + var imageInput = Resolve(api, field == null ? null : properties[field]); + bool array = (string)imageInput?["type"] == "array" && (string)Resolve(api, imageInput["items"])?["type"] == "string"; + if (field == null || (!array && (string)imageInput?["type"] != "string") + || array && (((int?)imageInput["minItems"] ?? 1) > 1 || ((int?)imageInput["maxItems"] ?? 1) < 1)) return null; + entry.ImageInputField = field; + entry.ImageInputIsArray = array; + if (kind == "image") { - string field = properties["image_urls"] != null ? "image_urls" : properties["image_url"] != null ? "image_url" : null; - var imageInput = Resolve(api, field == null ? null : properties[field]); - bool array = (string)imageInput?["type"] == "array" && (string)Resolve(api, imageInput["items"])?["type"] == "string"; - if (field == null || (!array && (string)imageInput?["type"] != "string") - || array && (((int?)imageInput["minItems"] ?? 1) > 1 || ((int?)imageInput["maxItems"] ?? 1) < 1)) return null; - entry.ImageInputField = field; - entry.ImageInputIsArray = array; + entry.EditModelId = id; + entry.EditSupportsNumImages = entry.SupportsNumImages; + entry.EditOutputFormat = entry.OutputFormat; } } foreach (string required in input["required"]?.Values() ?? Enumerable.Empty()) { - if (required == promptField || required == entry.DurationField || edit && required == entry.ImageInputField) continue; + if (required == promptField || required == entry.DurationField || imageInputRequired && required == entry.ImageInputField) continue; + if (kind == "model" && required == entry.TextureField) continue; if (kind == "image" && required == "num_images" && entry.SupportsNumImages) continue; if (kind == "image" && required == "output_format" && entry.OutputFormat != null) continue; return null; @@ -181,6 +214,11 @@ private static JObject Resolve(JObject api, JToken token) if (!reference.StartsWith(prefix, StringComparison.Ordinal)) return null; schema = api?["components"]?["schemas"]?[reference.Substring(prefix.Length)] as JObject; } + if (schema?["anyOf"] is JArray options) + { + var nonNull = options.Where(option => (string)option["type"] != "null").ToArray(); + if (nonNull.Length == 1) return Resolve(api, nonNull[0]); + } return schema?["$ref"] == null ? schema : null; } } diff --git a/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs new file mode 100644 index 000000000..eccc442d6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + internal static class ImageResultFormat + { + internal static string FromMetadata(string mediaType, string url = null) + { + switch (mediaType?.Split(';')[0].ToLowerInvariant()) + { + case "image/png": return "png"; + case "image/jpeg": return "jpg"; + case "image/webp": return "webp"; + case "image/svg+xml": return "svg"; + } + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + string ext = Path.GetExtension(uri.AbsolutePath).TrimStart('.').ToLowerInvariant(); + if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" || ext == "svg") return ext; + } + return null; + } + + internal static string FromBytes(byte[] bytes) + { + if (bytes == null) return null; + if (bytes.Length >= 8 && bytes[0] == 137 && bytes[1] == 80 && bytes[2] == 78 && bytes[3] == 71 + && bytes[4] == 13 && bytes[5] == 10 && bytes[6] == 26 && bytes[7] == 10) return "png"; + if (bytes.Length >= 3 && bytes[0] == 255 && bytes[1] == 216 && bytes[2] == 255) return "jpg"; + if (bytes.Length >= 12 && bytes[0] == 82 && bytes[1] == 73 && bytes[2] == 70 && bytes[3] == 70 + && bytes[8] == 87 && bytes[9] == 69 && bytes[10] == 66 && bytes[11] == 80) return "webp"; + return null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta new file mode 100644 index 000000000..7e882bc6a --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a451fd8da1044900960fc42405b91af0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs new file mode 100644 index 000000000..e08c64503 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + public static class OpenRouterModelCatalog + { + private const string Api = "https://openrouter.ai/api/v1/images/models"; + private static readonly TimeSpan Lifetime = TimeSpan.FromHours(24); + private sealed class Snapshot + { + public int Version = 1; + public DateTime CheckedAt; + public List Entries; + } + private static Snapshot snapshot; + private static bool loaded, fromDisk, isolated; + private static Task refresh; + private static DateTime attempted; + public static event Action Changed; + public static string LastError { get; private set; } + internal static IHttpTransport TransportOverrideForTests; + internal static string CachePathOverrideForTests; + internal static Func UtcNow = () => DateTime.UtcNow; + private static string CachePath => CachePathOverrideForTests ?? Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "MCPForUnity", "openrouter-image-catalog.json"); + + public static bool IsRefreshing => refresh != null && !refresh.IsCompleted; + public static string Source { get { Load(); return snapshot == null ? "bundled" : fromDisk ? "cache" : "live"; } } + public static string CheckedAt { get { Load(); return snapshot?.CheckedAt.ToString("O"); } } + public static bool IsStale { get { Load(); return snapshot == null || UtcNow() - snapshot.CheckedAt >= Lifetime; } } + internal static bool TryGet(out IReadOnlyList entries) { Load(); entries = snapshot?.Entries; return entries != null; } + + public static Task RefreshAsync(bool force = false) + { + Load(); + if (IsRefreshing) return refresh; + if (!force && !IsStale) return Task.FromResult(true); + if (isolated && TransportOverrideForTests == null) return Task.FromResult(false); + if (!force && UtcNow() - attempted < TimeSpan.FromMinutes(2)) return Task.FromResult(false); + attempted = UtcNow(); + return refresh = RefreshCore(); + } + + private static async Task RefreshCore() + { + try + { + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var entries = await Discover(ct.Token); + snapshot = new Snapshot { CheckedAt = UtcNow(), Entries = entries }; + fromDisk = false; + LastError = null; + Save(); + Changed?.Invoke(); + return true; + } + catch (Exception error) + { + LastError = error is OperationCanceledException ? "OpenRouter catalog refresh timed out. Previous catalog retained." : error.Message; + return false; + } + } + + private static async Task> Discover(CancellationToken ct) + { + var json = await Get(Api, ct); + if (!(json["data"] is JArray data)) throw new InvalidOperationException("Invalid OpenRouter model catalog."); + var entries = new List(); + foreach (JObject model in data) + { + string id = (string)model["id"]; + if (!FalModelSchema.SafeId(id) || model["architecture"]?["output_modalities"]?.Values().Contains("image") != true + || model["architecture"]?["input_modalities"]?.Values().Contains("text") != true) continue; + var parameters = model["supported_parameters"] as JObject; + if (parameters == null || !RasterFormat(parameters, out _)) continue; + var modes = Modes(parameters); + if (modes.Length == 0) continue; + entries.Add(new ModelEntry + { + Id = id, Label = (string)model["name"] ?? id, Kind = "image", Provider = "openrouter", + FromRefresh = true, Modes = modes, UseCase = "Image generation", RouterParameters = parameters, + ModelUrl = "https://openrouter.ai/" + id, + }); + } + var bundled = AssetGenModelCatalog.Bundled("openrouter", "image").Select(e => e.Id).ToArray(); + return entries.GroupBy(e => e.Id).Select(g => g.First()).OrderBy(e => bundled.Contains(e.Id) ? 0 : 1).ToList(); + } + + internal static async Task VerifyForGeneration(string id, string mode, CancellationToken ct) + { + if (!FalModelSchema.SafeId(id)) throw new InvalidOperationException("Invalid OpenRouter model ID."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + var entry = (await Discover(timeout.Token)).FirstOrDefault(e => e.Id == id); + if (entry == null) throw new InvalidOperationException($"OpenRouter model '{id}' is unavailable or incompatible. Refresh models and choose another model."); + mode = string.IsNullOrEmpty(mode) ? "text" : mode; + var json = await Get(Api + "/" + id + "/endpoints", timeout.Token); + if ((string)json["id"] != id || !(json["endpoints"] is JArray endpoints)) + throw new InvalidOperationException("Invalid OpenRouter endpoint catalog."); + foreach (JObject endpoint in endpoints) + { + var parameters = endpoint["supported_parameters"] as JObject; + string tag = (string)endpoint["provider_tag"]; + // Pin the chosen capabilities; a fallback provider may accept different fields. + if (parameters == null || string.IsNullOrEmpty(tag) || !Modes(parameters).Contains(mode) + || !RasterFormat(parameters, out string format)) continue; + entry.RouterProviderTag = tag; + entry.RouterParameters = parameters; + entry.OutputFormat = format; + entry.Modes = Modes(parameters); + entry.VerifiedAt = UtcNow().ToString("O"); + Load(); + int index = snapshot?.Entries.FindIndex(e => e.Id == id) ?? -1; + if (index >= 0) { snapshot.Entries[index] = entry; Save(); Changed?.Invoke(); } + return entry; + } + throw new InvalidOperationException($"OpenRouter model '{id}' has no compatible '{mode}' endpoint."); + } + + private static string[] Modes(JObject parameters) + { + var reference = parameters["input_references"]; + int minimum = (int?)reference?["min"] ?? 0, maximum = (int?)reference?["max"] ?? 0; + var modes = new List(); + if (minimum == 0) modes.Add("text"); + if (reference != null && minimum <= 1 && maximum >= 1) modes.Add("image"); + return modes.ToArray(); + } + + private static bool RasterFormat(JObject parameters, out string format) + { + format = null; + if (parameters["output_format"] == null) return true; + var values = parameters["output_format"]?["values"]?.Values().ToArray(); + format = new[] { "png", "jpeg", "jpg" }.FirstOrDefault(f => values?.Contains(f) == true); + return format != null; + } + + private static async Task Get(string url, CancellationToken ct) + { + var result = await (TransportOverrideForTests ?? new UnityWebRequestTransport()).SendAsync(new HttpRequestSpec { Method = "GET", Url = url }, ct); + if (result == null || result.Status < 200 || result.Status >= 300) + throw new InvalidOperationException($"OpenRouter catalog request failed (HTTP {result?.Status}). Previous catalog retained."); + return JObject.Parse(ProviderHttp.BodyText(result)); + } + + private static void Load() + { + if (loaded) return; + loaded = true; + try + { + if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > 2 * 1024 * 1024) return; + var candidate = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); + if (candidate?.Version != 1 || candidate.Entries == null || candidate.CheckedAt.Kind != DateTimeKind.Utc + || candidate.CheckedAt > UtcNow().AddMinutes(5) + || candidate.Entries.Any(e => e == null || e.Kind != "image" || e.Provider != "openrouter" || !FalModelSchema.SafeId(e.Id))) return; + snapshot = candidate; + fromDisk = true; + } + catch { /* Ignore corrupt caches. */ } + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(CachePath)); + File.WriteAllText(CachePath + ".tmp", JsonConvert.SerializeObject(snapshot)); + if (File.Exists(CachePath)) File.Replace(CachePath + ".tmp", CachePath, null); + else File.Move(CachePath + ".tmp", CachePath); + } + catch { /* The in-memory snapshot still works. */ } + } + + internal static void ReloadCacheForTests() { snapshot = null; loaded = false; } + internal static void ResetForTests(bool isolate = false) + { + snapshot = null; loaded = false; fromDisk = false; isolated = isolate; refresh = null; LastError = null; + attempted = DateTime.MinValue; TransportOverrideForTests = null; Changed = null; UtcNow = () => DateTime.UtcNow; + CachePathOverrideForTests = isolate ? Path.Combine(Path.GetTempPath(), "unused_or_catalog_" + Guid.NewGuid().ToString("N"), "catalog.json") : null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta new file mode 100644 index 000000000..8f43977a6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4abab199794e4abf89ac9767e2620940 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs index 026de16b3..49cb79083 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -20,6 +20,8 @@ public static IModelProviderAdapter Model(string id) return new TripoAdapter(); case "meshy": return new MeshyAdapter(); + case "fal": + return new FalModelAdapter(); default: throw new NotSupportedException($"Unknown model provider '{id}'."); } @@ -66,10 +68,11 @@ public static IReadOnlyList List() { new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "fal", Kind = "model", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } }, new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, 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. + // fal's three asset kinds share one secure-store key. new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } }, }; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs index 8b2db4ae1..a6e2ab1fa 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -34,7 +34,8 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); ModelEntry entry = req.CatalogEntry ?? AssetGenModelCatalog.Find(model); - var body = new JObject { [entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty }; + var body = new JObject(); + if (entry == null || entry.PromptField != null) body[entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty; if (entry == null || (image ? entry.EditSupportsNumImages : entry.SupportsNumImages)) body["num_images"] = 1; string outputFormat = image ? entry?.EditOutputFormat : entry?.OutputFormat; if (outputFormat != null) body["output_format"] = outputFormat; @@ -61,6 +62,14 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT if (!image && req.Width > 0 && req.Height > 0) body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; + return await SubmitQueueAsync(body, url.Substring(QueueBase.Length), apiKey, http, ct); + } + + internal static async Task SubmitQueueAsync(JObject body, string model, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (!FalModelSchema.SafeId(model)) throw new InvalidOperationException("Invalid fal model ID."); + string url = QueueBase + model; + ProviderHttp.RequireHost(url, QueueHost, apiKey, "fal submit"); var spec = new HttpRequestSpec @@ -84,7 +93,7 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); // Queue request URLs are namespaced by owner/app without the action sub-path, // so build from the base model id (not `url`, which may end in /edit). - responseUrl = QueueBase + model + "/requests/" + requestId; + responseUrl = QueueBase + string.Join("/", model.Split('/'), 0, 2) + "/requests/" + Uri.EscapeDataString(requestId); } // The response_url is provider-controlled; refuse to later attach the key to any host // other than the fal queue. @@ -92,7 +101,18 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT return responseUrl; } - public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + => PollQueueAsync(providerJobId, apiKey, http, ct, json => + { + var file = json["images"]?[0] ?? json["image"]; + return new ProviderPollResult + { + DownloadUrl = ExtractImageUrl(json), + ResultExt = ImageResultFormat.FromMetadata((string)file?["content_type"], (string)file?["url"]), + }; + }); + + internal static async Task PollQueueAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct, Func extract) { if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); string responseUrl = providerJobId; @@ -138,12 +158,13 @@ public async Task PollAsync(string providerJobId, string api HttpResult resultRes = await http.SendAsync(resultSpec, ct); JObject resultJson = ParseOk(resultRes, apiKey, "result"); + result = extract(resultJson); + result.State = ProviderPollState.Succeeded; result.Progress = 1f; - result.DownloadUrl = ExtractImageUrl(resultJson); if (string.IsNullOrEmpty(result.DownloadUrl)) { result.State = ProviderPollState.Failed; - result.Error = "fal completed but no image URL was present in the result."; + result.Error = "fal completed but no result URL was present in the result."; } return result; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs new file mode 100644 index 000000000..d16059715 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + public sealed class FalModelAdapter : IModelProviderAdapter + { + internal const string DefaultModel = "fal-ai/hunyuan3d-v3/text-to-3d"; + private ModelEntry profile; + public string Id => "fal"; + + public Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + profile = req.CatalogEntry; + if (profile == null || profile.VerifiedAt == null) throw new InvalidOperationException("Choose a verified fal 3D model."); + if (!string.Equals(req.Format ?? "glb", "glb", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("fal 3D generation currently supports GLB output. Choose format='glb'."); + if (!string.IsNullOrEmpty(req.Tier)) throw new InvalidOperationException("This fal adapter uses the endpoint's default tier."); + if (!req.Texture && profile.TextureField == null) + throw new InvalidOperationException("This model does not expose a texture toggle. Choose a model that supports it."); + var body = new JObject(); + if (profile.PromptField != null) body[profile.PromptField] = req.Prompt ?? ""; + if (profile.TextureField != null) body[profile.TextureField] = req.Texture; + if (req.Mode == "image") + { + string image = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body[profile.ImageInputField] = profile.ImageInputIsArray ? (JToken)new JArray(image) : image; + } + return FalAdapter.SubmitQueueAsync(body, profile.Id, apiKey, http, ct); + } + + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + => FalAdapter.PollQueueAsync(providerJobId, apiKey, http, ct, json => new ProviderPollResult + { + DownloadUrl = (string)json.SelectToken(profile.ModelOutputField + ".url"), ResultExt = "glb", + }); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta new file mode 100644 index 000000000..0feda610f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0efe148146db4e159bab4428a05b9093 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs index 3fb6fc2d0..b7337bbe2 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs @@ -10,8 +10,9 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// - /// OpenRouter image provider via the (synchronous) chat-completions endpoint with an - /// image-capable multimodal model. The image is returned inline (base64 data URL), so the + /// OpenRouter's synchronous Image API with verified per-endpoint capabilities. + /// Legacy direct callers without a catalog profile retain the chat response contract. The + /// image is returned inline, so the /// work happens in and returns it immediately. /// One adapter instance handles a single job (the job manager captures it for submit+poll). /// @@ -26,11 +27,13 @@ public sealed class OpenRouterAdapter : IImageProviderAdapter private byte[] _inlineData; private string _downloadUrl; private string _error; + private string _resultExt; public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) { if (req == null) throw new ArgumentNullException(nameof(req)); if (http == null) throw new ArgumentNullException(nameof(http)); + _inlineData = null; _downloadUrl = null; _error = null; _resultExt = null; string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; @@ -59,11 +62,28 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT ["content"] = content }) }; + bool imageApi = req.CatalogEntry?.RouterProviderTag != null; + if (imageApi) + { + var profile = req.CatalogEntry; + body = new JObject + { + ["model"] = model, ["prompt"] = req.Prompt ?? "", + ["provider"] = new JObject { ["only"] = new JArray(profile.RouterProviderTag), ["allow_fallbacks"] = false }, + }; + if (image) body["input_references"] = new JArray(new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } }); + if (profile.OutputFormat != null) body["output_format"] = profile.OutputFormat; + if (req.Width > 0 && req.Height > 0) + { + if (profile.RouterParameters?["size"] == null) throw new InvalidOperationException("This OpenRouter endpoint does not accept explicit pixel dimensions."); + body["size"] = req.Width + "x" + req.Height; + } + } var spec = new HttpRequestSpec { Method = "POST", - Url = Endpoint, + Url = imageApi ? "https://openrouter.ai/api/v1/images" : Endpoint, ContentType = "application/json", Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) }; @@ -71,6 +91,16 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT HttpResult res = await http.SendAsync(spec, ct); JObject json = ParseOk(res, apiKey); + if (imageApi) + { + var file = json["data"]?[0]; + _resultExt = ImageResultFormat.FromMetadata((string)file?["media_type"]); + try { _inlineData = Convert.FromBase64String((string)file?["b64_json"] ?? ""); } + catch { _error = "OpenRouter returned invalid image bytes."; } + if (_inlineData?.Length == 0) _error = "OpenRouter returned no image."; + if (_resultExt == "svg" || _resultExt == "webp") _error = "This Unity importer requires PNG or JPEG output."; + return "ready"; + } string url = ExtractImageUrl(json); if (string.IsNullOrEmpty(url)) @@ -85,10 +115,12 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; } try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); } catch { _error = "OpenRouter image was not valid base64."; } + _resultExt = ImageResultFormat.FromMetadata(url.Substring(5, Math.Max(0, url.IndexOf(';') - 5))); } else { _downloadUrl = url; + _resultExt = ImageResultFormat.FromMetadata(null, url); } return "ready"; } @@ -106,6 +138,7 @@ public Task PollAsync(string providerJobId, string apiKey, I result.State = ProviderPollState.Succeeded; result.InlineData = _inlineData; result.DownloadUrl = _downloadUrl; + result.ResultExt = _resultExt; } return Task.FromResult(result); } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs index 51c18386c..ed53f477d 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -31,6 +31,7 @@ public sealed class ProviderPollResult /// Request to generate a 3D model. Shared by every model provider adapter. public sealed class ModelGenRequest { + internal ModelEntry CatalogEntry; public string Provider; public string Mode; // text | image public string Prompt; diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs index ddde8e585..13dc3a177 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs @@ -70,35 +70,44 @@ public static object ListModels(ToolParams p, string kind, bool forceRefresh = f string provider = p.Get("provider")?.ToLowerInvariant(); var providers = AssetGenProviders.List().Where(info => info.Kind == kind && (string.IsNullOrEmpty(provider) || info.Id == provider)).ToList(); if (providers.Count == 0) return new ErrorResponse($"Unknown {kind} provider '{provider}'."); - if (forceRefresh && !providers.Any(info => info.Id == "fal")) - return new ErrorResponse("Live model refresh currently supports fal image and audio. Other providers use the bundled catalog."); + if (forceRefresh && !providers.Any(info => info.Id == "fal" || info.Id == "openrouter" && kind == "image")) + return new ErrorResponse("Live refresh supports fal and OpenRouter images. Tripo and Meshy use bundled models."); + string search = p.Get("search") ?? ""; + string mode = p.Get("mode"); + int limit = p.GetInt("limit", 50) ?? 50, offset = p.GetInt("offset", 0) ?? 0; + if (limit < 1 || limit > 200 || offset < 0) return new ErrorResponse("Use limit=1..200 and offset>=0."); var models = new List(); var catalogs = new List(); foreach (var info in providers) { - bool live = info.Id == "fal" && (kind == "image" || kind == "audio"); - if (live) _ = FalModelCatalog.RefreshAsync(kind, forceRefresh); + bool fal = info.Id == "fal", router = info.Id == "openrouter" && kind == "image"; + if (fal) _ = FalModelCatalog.RefreshAsync(kind, forceRefresh); + if (router) _ = OpenRouterModelCatalog.RefreshAsync(forceRefresh); foreach (var model in AssetGenModelCatalog.ForProvider(info.Id, kind)) + { + var modes = model.Modes ?? (kind == "audio" ? new[] { "text" } : new[] { "text", "image" }); + if (!string.IsNullOrEmpty(mode) && !modes.Contains(mode)) continue; + if ((model.Id + " " + model.Label + " " + model.UseCase).IndexOf(search, System.StringComparison.OrdinalIgnoreCase) < 0) continue; models.Add(new { id = model.Id, label = model.Label, provider = model.Provider, kind = model.Kind, use_case = model.UseCase, verified_at = model.VerifiedAt, - status = model.FromRefresh ? "verified" : "unverified", - capabilities = kind == "model" ? info.Capabilities : kind == "image" && (!model.FromRefresh || !string.IsNullOrEmpty(model.EditModelId)) - ? new[] { "text", "image" } : new[] { "text" }, + status = model.VerifiedAt != null ? "verified" : model.FromRefresh ? "discovered" : "unverified", + capabilities = modes, max_duration_seconds = model.MaxDurationSeconds, license_type = model.LicenseType, }); + } catalogs.Add(new { - provider = info.Id, source = live ? FalModelCatalog.Source(kind) : "bundled", - last_verified = live ? FalModelCatalog.VerifiedAt(kind) : null, - stale = !live || FalModelCatalog.IsStale(kind), - refreshing = live && FalModelCatalog.IsRefreshing(kind), - refresh_error = live ? FalModelCatalog.LastError(kind) : null, + provider = info.Id, source = fal ? FalModelCatalog.Source(kind) : router ? OpenRouterModelCatalog.Source : "bundled", + last_checked = fal ? FalModelCatalog.VerifiedAt(kind) : router ? OpenRouterModelCatalog.CheckedAt : null, + stale = fal ? FalModelCatalog.IsStale(kind) : !router || OpenRouterModelCatalog.IsStale, + refreshing = fal ? FalModelCatalog.IsRefreshing(kind) : router && OpenRouterModelCatalog.IsRefreshing, + refresh_error = fal ? FalModelCatalog.LastError(kind) : router ? OpenRouterModelCatalog.LastError : null, }); } - return new SuccessResponse("Model catalog. If refreshing is true, call list_models again after the refresh completes. Bundled entries are unverified; fal endpoints are rechecked before generation.", - new { models, catalogs }); + return new SuccessResponse("Model catalog. Repeat list_models while refreshing. Discovered models require compatibility verification; live endpoints are rechecked before generation.", + new { models = models.Skip(offset).Take(limit).ToList(), total = models.Count, offset, limit, has_more = offset < models.Count - limit, catalogs }); } } } diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs index 41155b6eb..e5e1dc150 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -31,9 +31,10 @@ public static object HandleCommand(JObject @params) case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("model"); case "list_models": return AssetGenToolHelpers.ListModels(p, "model"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "model", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 7f03a3cd2..33529d17f 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Threading; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; @@ -29,6 +30,7 @@ private static readonly (string Id, string Label)[] ModelProviders = { ("tripo", "Tripo"), ("meshy", "Meshy"), + ("fal", "fal (shared key)"), ("sketchfab", "Sketchfab"), }; @@ -51,6 +53,7 @@ private static readonly (string Id, string Label)[] ImageProviders = // recompute the glTFast notice when a toggle changes. private readonly List<(string Id, Toggle Toggle)> modelEnableToggles = new(); private readonly List<(VisualElement Container, string Kind, string Provider)> modelControls = new(); + private readonly Dictionary searches = new(); public VisualElement Root { get; private set; } @@ -60,12 +63,23 @@ public McpAssetGenSection(VisualElement root) CacheUIElements(); InitializeUI(); RegisterCallbacks(); - Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnCatalogChanged; FalModelCatalog.Changed += OnCatalogChanged; }); - Root.RegisterCallback(_ => FalModelCatalog.Changed -= OnCatalogChanged); - if (Root.panel != null) FalModelCatalog.Changed += OnCatalogChanged; + Root.RegisterCallback(_ => SubscribeCatalogs()); + Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnCatalogChanged; OpenRouterModelCatalog.Changed -= OnRouterChanged; }); + if (Root.panel != null) SubscribeCatalogs(); + Root.schedule.Execute(() => { if (!modelControls.Any(c => FalModelCatalog.IsRefreshing(c.Kind)) && !OpenRouterModelCatalog.IsRefreshing) _ = RefreshCatalog(false); }).Every(60000); _ = RefreshCatalog(false); } + private void SubscribeCatalogs() + { + FalModelCatalog.Changed -= OnCatalogChanged; + FalModelCatalog.Changed += OnCatalogChanged; + OpenRouterModelCatalog.Changed -= OnRouterChanged; + OpenRouterModelCatalog.Changed += OnRouterChanged; + } + + private void OnRouterChanged() => OnCatalogChanged("image"); + private void CacheUIElements() { providersContainer = Root.Q("assetgen-providers-container"); @@ -131,7 +145,7 @@ private void RegisterCallbacks() if (refreshButton != null) { refreshButton.tooltip = - "Fetch current fal image and sound models and re-check key presence. Other providers use the bundled catalog."; + "Refresh fal image, sound and 3D models, and OpenRouter images."; refreshButton.clicked += OnRefreshClicked; } } @@ -158,16 +172,17 @@ public void Refresh() private async Task RefreshCatalog(bool force) { refreshButton?.SetEnabled(false); - if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, "Checking fal model catalog…", true); + if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, "Checking model catalogs…", true); try { - await Task.WhenAll(FalModelCatalog.RefreshAsync("image", force), FalModelCatalog.RefreshAsync("audio", force)); + await Task.WhenAll(FalModelCatalog.RefreshAsync("image", force), FalModelCatalog.RefreshAsync("audio", force), + FalModelCatalog.RefreshAsync("model", force), OpenRouterModelCatalog.RefreshAsync(force)); // Only replace model controls: an automatic refresh must preserve unsaved API-key input. OnCatalogChanged(null); - string error = FalModelCatalog.LastError("image") ?? FalModelCatalog.LastError("audio"); + string error = FalModelCatalog.LastError("image") ?? FalModelCatalog.LastError("audio") ?? FalModelCatalog.LastError("model") ?? OpenRouterModelCatalog.LastError; string verified = FalModelCatalog.VerifiedAt("audio") ?? FalModelCatalog.VerifiedAt("image"); string when = DateTime.TryParse(verified, out var time) ? time.ToLocalTime().ToString("g") : "unknown"; - string label = error != null ? error : "fal models verified " + when + " · other providers use bundled models"; + string label = error != null ? error : "Catalogs checked " + when + " · refreshed automatically every 24 hours"; if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, label, error == null); } finally { refreshButton?.SetEnabled(true); } @@ -177,7 +192,7 @@ private void OnCatalogChanged(string kind) { foreach (var control in modelControls.ToArray()) { - if (control.Provider != "fal" || kind != null && control.Kind != kind) continue; + if (control.Provider != "fal" && control.Provider != "openrouter" || kind != null && control.Kind != kind) continue; control.Container.Clear(); PopulateModelDropdown(control.Container, control.Kind, control.Provider); } @@ -424,6 +439,21 @@ void SaveKeyFromField() /// private void AddModelDropdown(VisualElement parent, string kind, string providerId) { + if (providerId == "fal" || providerId == "openrouter") + { + var search = new TextField("Search models") { name = "model-search-" + kind + "-" + providerId }; + string key = kind + "/" + providerId; + search.SetValueWithoutNotify(searches.TryGetValue(key, out var term) ? term : ""); + parent.Add(search); + search.RegisterValueChangedCallback(evt => + { + searches[key] = evt.newValue ?? ""; + var control = modelControls.FirstOrDefault(c => c.Kind == kind && c.Provider == providerId); + if (control.Container == null) return; + control.Container.Clear(); + PopulateModelDropdown(control.Container, kind, providerId); + }); + } var container = new VisualElement(); parent.Add(container); modelControls.Add((container, kind, providerId)); @@ -432,18 +462,20 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider private void PopulateModelDropdown(VisualElement parent, string kind, string providerId) { - IReadOnlyList models = AssetGenModelCatalog.ForProvider(providerId, kind); + var all = AssetGenModelCatalog.ForProvider(providerId, kind); + string selectedId = AssetGenPrefs.GetSelectedModel(kind, providerId); + if (string.IsNullOrEmpty(selectedId)) selectedId = AssetGenModelCatalog.DefaultModelId(providerId, kind); + string term = searches.TryGetValue(kind + "/" + providerId, out var query) ? query : ""; + IReadOnlyList models = all.Where(m => m.Id == selectedId || (m.Id + " " + m.Label + " " + m.UseCase).IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); if (models.Count == 0) { - if (providerId == "fal") parent.Add(new Label("No compatible models found. Refresh the catalog to check again.")); + parent.Add(new Label("No models found. Clear the search or refresh the catalog.")); return; } var choices = new List(); foreach (ModelEntry m in models) choices.Add(m.Label + " (" + m.Id + ")"); - string selectedId = AssetGenPrefs.GetSelectedModel(kind, providerId); - if (string.IsNullOrEmpty(selectedId)) selectedId = AssetGenModelCatalog.DefaultModelId(providerId, kind); ModelEntry selected = models.FirstOrDefault(model => model.Id == selectedId); int selectedIndex = selected == null ? choices.Count : models.ToList().IndexOf(selected); if (selected == null) choices.Add("Saved model unavailable — choose another (" + selectedId + ")"); @@ -491,9 +523,29 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro AssetGenPrefs.SetSelectedModel(kind, providerId, picked.Id); UpdateModelMeta(meta, picked); UpdateModelCaveat(caveat, picked); + if (providerId == "fal" || providerId == "openrouter") _ = VerifySelection(picked, meta); }); } + private static async Task VerifySelection(ModelEntry picked, Label meta) + { + meta.text = "Checking compatibility…"; + try + { + string key = null; + if (picked.Provider == "fal") try { SecureKeyStore.Current.TryGet("fal", out key); } catch { } + string mode = picked.Modes?.FirstOrDefault() ?? "text"; + var verified = picked.Provider == "fal" + ? await FalModelCatalog.VerifyForGeneration(picked.Id, picked.Kind, mode, CancellationToken.None, key) + : await OpenRouterModelCatalog.VerifyForGeneration(picked.Id, mode, CancellationToken.None); + if (AssetGenPrefs.GetSelectedModel(picked.Kind, picked.Provider) == picked.Id) UpdateModelMeta(meta, verified); + } + catch (Exception error) + { + if (AssetGenPrefs.GetSelectedModel(picked.Kind, picked.Provider) == picked.Id) meta.text = SecretRedactor.Scrub(error.Message); + } + } + /// /// Audio row: no enable toggle and no key field — audio reuses the single fal key owned by /// the Image "fal" row. Surfaces that key's presence and a fal-audio model dropdown. @@ -555,7 +607,7 @@ private static void UpdateModelMeta(Label label, ModelEntry m) // Lyria advertises a max but takes no duration input, so showing a hint would mislead. if (m.MaxDurationSeconds > 0f && !string.IsNullOrEmpty(m.DurationField)) parts.Add($"≤{m.MaxDurationSeconds:0}s"); if (m.Loopable) parts.Add("loopable"); - parts.Add(m.FromRefresh ? "verified catalog" : "bundled · unverified"); + parts.Add(m.VerifiedAt != null ? "compatibility verified" : m.FromRefresh ? "discovered · checked before generation" : "bundled · unverified"); if (!string.IsNullOrEmpty(m.LicenseType)) parts.Add("license: " + m.LicenseType); label.text = string.Join(" · ", parts); label.tooltip = m.ModelUrl ?? m.Id; diff --git a/Server/src/cli/commands/asset_gen.py b/Server/src/cli/commands/asset_gen.py index d6928ab3f..a0cda380c 100644 --- a/Server/src/cli/commands/asset_gen.py +++ b/Server/src/cli/commands/asset_gen.py @@ -32,25 +32,30 @@ def _emit(result, config, verb): @click.option("--kind", type=click.Choice(["audio", "image", "model"]), required=True, help="Asset kind to discover.") @click.option("--provider", default=None, help="Restrict results to one provider.") -@click.option("--refresh", is_flag=True, help="Force a background fal catalog refresh (audio/image only).") +@click.option("--refresh", is_flag=True, help="Force live catalog discovery.") +@click.option("--search", default=None, help="Filter models by name, id or use case.") +@click.option("--mode", type=click.Choice(["text", "image"]), default=None, help="Filter models by input mode.") +@click.option("--limit", type=click.IntRange(1, 200), default=None, help="Page size (default 50).") +@click.option("--offset", type=click.IntRange(min=0), default=None, help="Page offset (default 0).") @handle_unity_errors -def list_models(kind: str, provider: Optional[str], refresh: bool): +def list_models(kind: str, provider: Optional[str], refresh: bool, search: Optional[str], mode: Optional[str], limit: Optional[int], offset: Optional[int]): """List compatible models, capabilities, and catalog freshness. If catalogs[].refreshing is true, repeat this command without --refresh later. - Tripo, Meshy and OpenRouter currently return bundled, unverified entries. + Discovered entries are checked for compatibility before generation. """ - if refresh and (kind == "model" or provider not in (None, "fal")): - raise click.UsageError("--refresh currently supports fal audio and image models.") + if refresh and provider is not None and not (provider == "fal" or provider == "openrouter" and kind == "image"): + raise click.UsageError("--refresh supports fal and OpenRouter images.") params = {"action": "refresh_models" if refresh else "list_models"} if provider is not None: params["provider"] = provider + params.update({k: v for k, v in {"search": search, "mode": mode, "limit": limit, "offset": offset}.items() if v is not None}) config = get_config() click.echo(format_output(run_command(f"generate_{kind}", params, config), config.format)) @asset_gen.command("generate-model") -@click.option("--provider", default=None, help="Provider id (tripo, meshy).") +@click.option("--provider", default=None, help="Provider id (tripo, meshy, fal).") @click.option("--mode", default=None, help="Generation mode: text or image.") @click.option("--prompt", default=None, help="Text prompt for text->3D.") @click.option("--image-path", default=None, help="Source image path for image->3D.") diff --git a/Server/src/services/tools/generate_audio.py b/Server/src/services/tools/generate_audio.py index 87421acaf..7da6f375b 100644 --- a/Server/src/services/tools/generate_audio.py +++ b/Server/src/services/tools/generate_audio.py @@ -22,7 +22,7 @@ "Generate audio (sound effects and background music) with fal.ai models and import " "them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in " "the editor's secure store (shared with image generation) and never crosses the bridge.\n\n" - "Use list_models for current compatible models. Omit model to use the " + "Use list_models to discover current models; compatibility is checked before generation. Omit model to use the " "Asset Generation tab selection.\n\n" "ACTIONS:\n" "- generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the " @@ -52,6 +52,10 @@ async def generate_audio( name: Annotated[str, "Base name for the imported asset."] | None = None, output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None, job_id: Annotated[str, "Job id for status/cancel."] | None = None, + search: Annotated[str, "Filter list_models by name, id or use case."] | None = None, + mode: Annotated[str, "Filter list_models by input mode (text)."] | None = None, + limit: Annotated[int, "Model page size (1..200; default 50)."] | None = None, + offset: Annotated[int, "Model page offset (default 0)."] | None = None, ) -> dict[str, Any]: unity_instance = await get_unity_instance_from_context(ctx) @@ -64,6 +68,10 @@ async def generate_audio( "name": name, "outputFolder": output_folder, "jobId": job_id, + "search": search, + "mode": mode, + "limit": limit, + "offset": offset, } # Remove None values diff --git a/Server/src/services/tools/generate_image.py b/Server/src/services/tools/generate_image.py index 21bfbd964..81be15169 100644 --- a/Server/src/services/tools/generate_image.py +++ b/Server/src/services/tools/generate_image.py @@ -31,8 +31,8 @@ "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured image providers and capabilities (no key values).\n" - "- list_models: List models, modes and freshness. OpenRouter uses bundled models.\n" - "- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true." + "- list_models: Search/paginate live fal and OpenRouter models, modes and freshness.\n" + "- refresh_models: Force discovery. Repeat list_models while catalogs[].refreshing is true." ), annotations=ToolAnnotations( title="Generate Image", @@ -58,6 +58,9 @@ async def generate_image( name: Annotated[str, "Base name for the imported asset."] | None = None, output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None, job_id: Annotated[str, "Job id for status/cancel."] | None = None, + search: Annotated[str, "Filter list_models by name, id or use case."] | None = None, + limit: Annotated[int, "Model page size (1..200; default 50)."] | None = None, + offset: Annotated[int, "Model page offset (default 0)."] | None = None, ) -> dict[str, Any]: unity_instance = await get_unity_instance_from_context(ctx) @@ -75,6 +78,9 @@ async def generate_image( "name": name, "outputFolder": output_folder, "jobId": job_id, + "search": search, + "limit": limit, + "offset": offset, } # Remove None values diff --git a/Server/src/services/tools/generate_model.py b/Server/src/services/tools/generate_model.py index 3dd5e9f1e..4675bfa3b 100644 --- a/Server/src/services/tools/generate_model.py +++ b/Server/src/services/tools/generate_model.py @@ -19,7 +19,7 @@ @mcp_for_unity_tool( group="asset_gen", description=( - "Generate 3D models with AI providers (Tripo, Meshy) and import them " + "Generate 3D models with AI providers (Tripo, Meshy, fal) and import them " "into the Unity project. Bring-your-own-key: provider keys live in the editor's " "secure store and never cross the bridge.\n\n" "ACTIONS:\n" @@ -30,7 +30,8 @@ "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured 3D providers and capabilities (no key values).\n" - "- list_models: List bundled Tripo/Meshy models (unverified)." + "- list_models: Search/paginate live fal 3D models and bundled Tripo/Meshy models.\n" + "- refresh_models: Refresh fal discovery. Repeat list_models while catalogs[].refreshing is true." ), annotations=ToolAnnotations( title="Generate Model", @@ -39,10 +40,10 @@ ) async def generate_model( ctx: Context, - action: Annotated[Literal["generate", "status", "cancel", "list_providers", "list_models"], + action: Annotated[Literal["generate", "status", "cancel", "list_providers", "list_models", "refresh_models"], "Action to perform."], - provider: Annotated[str, "Provider id (tripo, meshy)."] | None = None, + provider: Annotated[str, "Provider id (tripo, meshy, fal). fal supports GLB output."] | None = None, mode: Annotated[str, "Generation mode: text or image."] | None = None, prompt: Annotated[str, "Text prompt for text->3D."] | None = None, image_path: Annotated[str, "Path to a source image for image->3D."] | None = None, @@ -56,6 +57,9 @@ async def generate_model( name: Annotated[str, "Base name for the imported asset."] | None = None, output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None, job_id: Annotated[str, "Job id for status/cancel."] | None = None, + search: Annotated[str, "Filter list_models by name, id or use case."] | None = None, + limit: Annotated[int, "Model page size (1..200; default 50)."] | None = None, + offset: Annotated[int, "Model page offset (default 0)."] | None = None, ) -> dict[str, Any]: unity_instance = await get_unity_instance_from_context(ctx) @@ -74,6 +78,9 @@ async def generate_model( "name": name, "outputFolder": output_folder, "jobId": job_id, + "search": search, + "limit": limit, + "offset": offset, } # Remove None values diff --git a/Server/tests/test_asset_gen_catalog.py b/Server/tests/test_asset_gen_catalog.py index c7577511b..e31bfec7d 100644 --- a/Server/tests/test_asset_gen_catalog.py +++ b/Server/tests/test_asset_gen_catalog.py @@ -16,6 +16,7 @@ ("audio", "list_models"), ("audio", "refresh_models"), ("image", "list_models"), ("image", "refresh_models"), ("model", "list_models"), + ("model", "refresh_models"), ]) def test_discovery_actions_are_advertised_and_preserve_catalog_response(kind, action): module = importlib.import_module(f"services.tools.generate_{kind}") @@ -52,12 +53,34 @@ def test_cli_lists_catalog_without_generation_or_key_parameters(kind, refresh): @pytest.mark.parametrize("arguments", [ - ["list-models", "--kind", "model", "--refresh"], - ["list-models", "--kind", "image", "--provider", "openrouter", "--refresh"], + ["list-models", "--kind", "model", "--provider", "meshy", "--refresh"], + ["list-models", "--kind", "audio", "--provider", "openrouter", "--refresh"], ]) def test_cli_rejects_unsupported_live_refresh_before_contacting_unity(arguments): with patch("cli.commands.asset_gen.run_command") as run: result = CliRunner().invoke(asset_gen, arguments) assert result.exit_code != 0 - assert "currently supports fal" in result.output + assert "supports fal" in result.output run.assert_not_called() + + +@pytest.mark.parametrize("kind,provider", [("model", "fal"), ("image", "openrouter")]) +def test_cli_refreshes_new_catalogs_and_forwards_search_and_paging(kind, provider): + config = CLIConfig(host="127.0.0.1", port=8080, timeout=30, format="json", unity_instance=None) + with patch("cli.commands.asset_gen.get_config", return_value=config), \ + patch("cli.commands.asset_gen.run_command", return_value={"success": True}) as run: + result = CliRunner().invoke(asset_gen, ["list-models", "--kind", kind, "--provider", provider, + "--refresh", "--search", "flux", "--mode", "image", "--limit", "20", "--offset", "40"]) + assert result.exit_code == 0, result.output + assert run.call_args.args[1] == {"action": "refresh_models", "provider": provider, + "search": "flux", "mode": "image", "limit": 20, "offset": 40} + + +@pytest.mark.parametrize("kind", ["audio", "image", "model"]) +def test_mcp_forwards_discovery_filters(kind): + module = importlib.import_module(f"services.tools.generate_{kind}") + tool = getattr(module, f"generate_{kind}") + with patch.object(module, "get_unity_instance_from_context", AsyncMock(return_value="unity-1")), \ + patch.object(module, "send_with_unity_instance", AsyncMock(return_value={"success": True})) as send: + asyncio.run(tool(MagicMock(), action="list_models", search="new", limit=10, offset=20)) + assert send.call_args.args[3] == {"action": "list_models", "search": "new", "limit": 10, "offset": 20} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs index 05dee162e..e4a22e56e 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs @@ -90,6 +90,30 @@ private static void Pump(string jobId) Assert.Less(guard, 50, "state machine did not reach a terminal state"); } + [TestCase(false), TestCase(true)] + public void ImageResult_JpegBytesUseJpgExtension_ForDownloadedAndInlineResults(bool inline) + { + byte[] jpeg = { 255, 216, 255, 224, 0, 16 }; + string env = "MCPFORUNITY_OPENROUTER_API_KEY"; + string old = Environment.GetEnvironmentVariable(env); + Environment.SetEnvironmentVariable(env, "test-key"); + try + { + _fake.Handler = r => r.Method == "POST" ? Json(inline + ? "{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + Convert.ToBase64String(jpeg) + "\"}}]}}]}" + : "{\"response_url\":\"https://queue.fal.run/test/image/requests/r1\"}") + : r.Url.EndsWith("/status") ? Json("{\"status\":\"COMPLETED\"}") + : r.Url.Contains("queue.fal.run") ? Json("{\"images\":[{\"url\":\"https://example.com/image.png\",\"content_type\":\"image/png\"}]}") + : new HttpResult { Status = 200, IsSuccess = true, Body = jpeg }; + var job = AssetGenJobManager.StartImageGeneration(new ImageGenRequest { Provider = inline ? "openrouter" : "fal", Model = "test/image", Prompt = "cat", OutputFolder = TestFolder }); + Pump(job.JobId); + Assert.AreEqual(AssetGenJobState.Done, job.State, job.Error); + StringAssert.EndsWith(".jpg", job.AssetPath); + Assert.AreEqual("jpg", job.Format); + } + finally { Environment.SetEnvironmentVariable(env, old); } + } + [Test] public void EndToEnd_ReachesDone_WithAssetPath() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs index 75bf02728..e1d38c0df 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs @@ -57,6 +57,7 @@ internal static JObject Endpoint(string id, string kind = "audio", string prompt var input = new JObject { ["type"] = "object", ["properties"] = new JObject { [prompt] = new JObject { ["type"] = "string" } }, ["required"] = new JArray(prompt) }; var output = new JObject { ["type"] = "object", ["properties"] = kind == "audio" ? new JObject { ["audio"] = new JObject { ["$ref"] = "#/components/schemas/File" } } + : kind == "model" ? new JObject { ["model_glb"] = new JObject { ["$ref"] = "#/components/schemas/File" } } : new JObject { ["images"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["$ref"] = "#/components/schemas/File" } } } }; if (edit) { @@ -66,7 +67,7 @@ internal static JObject Endpoint(string id, string kind = "audio", string prompt return new JObject { ["endpoint_id"] = id, - ["metadata"] = new JObject { ["status"] = "active", ["category"] = edit ? "image-to-image" : kind == "audio" ? "text-to-audio" : "text-to-image", ["display_name"] = "Test model", ["tags"] = new JArray(kind == "audio" ? "music" : "image"), ["updated_at"] = "2026-10-01T12:00:00Z" }, + ["metadata"] = new JObject { ["status"] = "active", ["category"] = kind == "model" ? edit ? "image-to-3d" : "text-to-3d" : edit ? "image-to-image" : kind == "audio" ? "text-to-audio" : "text-to-image", ["display_name"] = "Test model", ["tags"] = new JArray(kind == "audio" ? "music" : "image"), ["updated_at"] = "2026-10-01T12:00:00Z" }, ["openapi"] = new JObject { ["paths"] = new JObject @@ -87,7 +88,8 @@ private void Serve(params JObject[] models) http.Handler = request => { bool details = request.Url.Contains("endpoint_id="); - string category = request.Url.Contains("category=text-to-image") ? "text-to-image" : "text-to-audio"; + string category = new[] { "text-to-image", "image-to-image", "text-to-3d", "image-to-3d", "text-to-audio" } + .FirstOrDefault(value => request.Url.Contains("category=" + value)); return Models(models.Where(model => details ? request.Url.Contains("endpoint_id=" + Uri.EscapeDataString((string)model["endpoint_id"]) + "&") : (string)model["metadata"]["category"] == category).ToArray()); @@ -95,6 +97,101 @@ private void Serve(params JObject[] models) } private bool Refresh(string kind = "audio", bool force = true) => FalModelCatalog.RefreshAsync(kind, force).GetAwaiter().GetResult(); + [Test] + public void CompleteCatalog_IsNotLimitedToEagerSchemaShortlist_AndCanBeSearched() + { + var endpoints = Enumerable.Range(0, 40).Select(i => Endpoint("test/music-" + i.ToString("D2"))).ToArray(); + Serve(endpoints); + Assert.IsTrue(Refresh()); + var entries = AssetGenModelCatalog.ForProvider("fal", "audio"); + Assert.AreEqual(40, entries.Count); + Assert.Less(entries.Count(e => e.VerifiedAt != null), entries.Count); + var discovered = entries.First(e => e.VerifiedAt == null); + Assert.IsTrue(discovered.FromRefresh); + var page = JObject.FromObject(GenerateAudio.HandleCommand(new JObject { ["action"] = "list_models", ["search"] = "music-", ["offset"] = 30, ["limit"] = 5 })); + Assert.AreEqual(40, (int)page["data"]["total"]); + Assert.AreEqual(5, page["data"]["models"].Count()); + Assert.IsTrue((bool)page["data"]["has_more"]); + Assert.AreEqual("discovered", (string)page["data"]["models"][0]["status"]); + var verified = FalModelCatalog.VerifyForGeneration(discovered.Id, "audio", "text", CancellationToken.None).GetAwaiter().GetResult(); + Assert.IsNotNull(verified.VerifiedAt); + Assert.IsNotNull(AssetGenModelCatalog.Find(discovered.Id).VerifiedAt); + } + + [Test] + public void NativeImageEndpoint_DoesNotRequireAGuessedEditAlias() + { + Serve(Endpoint("test/restyle", "image", edit: true)); + Assert.IsTrue(Refresh("image")); + var entry = FalModelCatalog.VerifyForGeneration("test/restyle", "image", "image", CancellationToken.None).GetAwaiter().GetResult(); + Assert.AreEqual("test/restyle", entry.EditModelId); + CollectionAssert.AreEqual(new[] { "image" }, entry.Modes); + Assert.Throws(() => FalModelCatalog.VerifyForGeneration(entry.Id, "image", "text", CancellationToken.None).GetAwaiter().GetResult()); + } + + [TestCase("audio"), TestCase("image"), TestCase("model")] + public void DirectJobWithNoModel_ResolvesCurrentCatalogBeforePreflight(string kind) + { + string oldKey = Environment.GetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY"); + string selection = AssetGenPrefs.GetSelectedModel(kind, "fal"); + Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", "test-key"); + AssetGenPrefs.SetSelectedModel(kind, "fal", ""); + try + { + string id = "test/replacement-" + kind; + Serve(Endpoint(id, kind)); + Assert.IsTrue(Refresh(kind)); + var paid = new FakeHttpTransport { Handler = _ => Json(new JObject { ["request_id"] = "r1", ["response_url"] = "https://queue.fal.run/test/app/requests/r1" }) }; + AssetGenJobManager.TransportOverrideForTests = paid; + AssetGenJob job = kind == "audio" ? AssetGenJobManager.StartAudioGeneration(new AudioGenRequest { Provider = "fal", Prompt = "rain" }) + : kind == "image" ? AssetGenJobManager.StartImageGeneration(new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "rain" }) + : AssetGenJobManager.StartModelGeneration(new ModelGenRequest { Provider = "fal", Mode = "text", Prompt = "chair" }); + AssetGenJobManager.TryAdvanceForTests(job.JobId); + AssetGenJobManager.TryAdvanceForTests(job.JobId); + Assert.AreNotEqual(AssetGenJobState.Failed, job.State, job.Error); + Assert.AreEqual("https://queue.fal.run/" + id, paid.RecordedRequests.First().Url); + } + finally + { + Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", oldKey); + AssetGenPrefs.SetSelectedModel(kind, "fal", selection); + } + } + + [Test] + public void Batch404_DoesNotHideActiveModels_WhenAnotherModelIsRemoved() + { + const string removed = "test/music-missing"; + http.Handler = r => !r.Url.Contains("endpoint_id=") ? Models(Endpoint(Music), Endpoint(removed)) + : r.Url.Contains(Uri.EscapeDataString(removed)) ? new HttpResult { Status = 404, Text = "{\"error\":{\"type\":\"not_found\"}}" } + : Models(Endpoint(Music)); + Assert.IsTrue(Refresh()); + Assert.AreEqual(Music, AssetGenModelCatalog.ForProvider("fal", "audio").Single().Id); + } + + [Test] + public void Fal3D_DiscoveryAndPreflight_UseNativeTextAndImageSchemas() + { + Serve(Endpoint("test/mesh-text", "model"), Endpoint("test/mesh-image", "model", edit: true)); + Assert.IsTrue(Refresh("model")); + CollectionAssert.AreEquivalent(new[] { "test/mesh-text", "test/mesh-image" }, AssetGenModelCatalog.ForProvider("fal", "model").Select(e => e.Id)); + var entry = FalModelCatalog.VerifyForGeneration("test/mesh-image", "model", "image", CancellationToken.None).GetAwaiter().GetResult(); + Assert.AreEqual("model_glb", entry.ModelOutputField); + var adapter = new FalModelAdapter(); + var submit = new FakeHttpTransport { Handler = r => r.Method == "POST" ? Json(new JObject { ["request_id"] = "r1" }) + : r.Url.EndsWith("/status") ? Json(new JObject { ["status"] = "COMPLETED" }) + : Json(new JObject { ["model_glb"] = new JObject { ["url"] = "https://example.com/model.glb" } }) }; + string pid = adapter.SubmitAsync(new ModelGenRequest { Mode = "image", CatalogEntry = entry, ImageUrl = "https://example.com/ref.jpg" }, "test-key", submit, CancellationToken.None).GetAwaiter().GetResult(); + Assert.AreEqual("https://queue.fal.run/test/mesh-image/requests/r1", pid); + var body = JObject.Parse(Encoding.UTF8.GetString(submit.RecordedRequests[0].Body)); + Assert.AreEqual("https://example.com/ref.jpg", (string)body["image_url"]); + Assert.IsNull(body["image_urls"]); + var result = adapter.PollAsync(pid, "test-key", submit, CancellationToken.None).GetAwaiter().GetResult(); + Assert.AreEqual("glb", result.ResultExt); + Assert.AreEqual("https://example.com/model.glb", result.DownloadUrl); + Assert.Throws(() => adapter.SubmitAsync(new ModelGenRequest { CatalogEntry = entry, Format = "fbx" }, "test-key", submit, CancellationToken.None)); + } + [Test] public void Refresh_DiscoversModels_WithoutKeys_AndSharesToolCatalog() { @@ -425,7 +522,7 @@ public IEnumerator BackgroundRefresh_PreservesUnsavedApiKeyInput() var field = root.Query().ToList().First(candidate => candidate.isPasswordField); field.value = "unsaved-test-input"; delayed.First.SetResult(Models()); - while (FalModelCatalog.IsRefreshing("image") || FalModelCatalog.IsRefreshing("audio")) yield return null; + while (FalModelCatalog.IsRefreshing("image") || FalModelCatalog.IsRefreshing("audio") || FalModelCatalog.IsRefreshing("model") || OpenRouterModelCatalog.IsRefreshing) yield return null; yield return null; Assert.AreEqual("unsaved-test-input", field.value); Assert.IsTrue(root.Contains(field), "The automatic refresh must not replace key-entry controls."); @@ -441,15 +538,18 @@ public IEnumerator LivePublicCatalog_RefreshAndExactEndpointVerification() FalModelCatalog.UtcNow = () => DateTime.UtcNow; var audio = FalModelCatalog.RefreshAsync("audio", true); var image = FalModelCatalog.RefreshAsync("image", true); - while (!audio.IsCompleted || !image.IsCompleted) yield return null; + var model = FalModelCatalog.RefreshAsync("model", true); + while (!audio.IsCompleted || !image.IsCompleted || !model.IsCompleted) yield return null; Assert.IsTrue(audio.Result, FalModelCatalog.LastError("audio")); Assert.IsTrue(image.Result, FalModelCatalog.LastError("image")); - foreach (string kind in new[] { "audio", "image" }) + Assert.IsTrue(model.Result, FalModelCatalog.LastError("model")); + foreach (string kind in new[] { "audio", "image", "model" }) { var entries = AssetGenModelCatalog.ForProvider("fal", kind); Assert.IsNotEmpty(entries); - Assert.IsTrue(entries.All(entry => entry.FromRefresh && !string.IsNullOrEmpty(entry.VerifiedAt))); - TestContext.WriteLine(kind + ": " + string.Join(", ", entries.Select(entry => entry.Id))); + Assert.IsTrue(entries.All(entry => entry.FromRefresh)); + Assert.IsTrue(entries.Any(entry => !string.IsNullOrEmpty(entry.VerifiedAt))); + TestContext.WriteLine(kind + ": " + entries.Count + " discovered; " + entries.Count(e => e.VerifiedAt != null) + " verified"); var verify = FalModelCatalog.VerifyForGeneration(entries[0].Id, kind, "text", CancellationToken.None); while (!verify.IsCompleted) yield return null; Assert.IsFalse(verify.IsFaulted, verify.Exception?.ToString()); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs new file mode 100644 index 000000000..2c3ea0219 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using MCPForUnity.Editor.Tools.AssetGen; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace MCPForUnityTests.Editor.AssetGen +{ + public class OpenRouterModelCatalogTests + { + private const string Id = "test/image-v3"; + private FakeHttpTransport http; + private string directory; + private DateTime now; + private static HttpResult Json(JObject body) => new HttpResult { Status = 200, Text = body.ToString() }; + private static JObject Parameters(int minimum = 0, int maximum = 1) => new JObject + { + ["input_references"] = new JObject { ["type"] = "range", ["min"] = minimum, ["max"] = maximum }, + ["output_format"] = new JObject { ["type"] = "enum", ["values"] = new JArray("jpeg", "png") }, + }; + private static JObject Model(string id = Id, JObject parameters = null) => new JObject + { + ["id"] = id, ["name"] = "New image model", + ["architecture"] = new JObject { ["input_modalities"] = new JArray("text", "image"), ["output_modalities"] = new JArray("image") }, + ["supported_parameters"] = parameters ?? Parameters(), + // Untrusted endpoint URL must never be followed. + ["endpoints"] = "https://untrusted.invalid/endpoints", + }; + private static JObject Endpoint(JObject parameters, string tag = "vendor") => new JObject + { + ["provider_tag"] = tag, ["supported_parameters"] = parameters, + }; + + [SetUp] + public void SetUp() + { + AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + directory = Path.Combine(Path.GetTempPath(), "or_catalog_" + Guid.NewGuid().ToString("N")); + OpenRouterModelCatalog.CachePathOverrideForTests = Path.Combine(directory, "catalog.json"); + now = new DateTime(2026, 10, 3, 12, 0, 0, DateTimeKind.Utc); + OpenRouterModelCatalog.UtcNow = () => now; + http = new FakeHttpTransport { Handler = r => Json(r.Url.EndsWith("/endpoints") + ? new JObject { ["id"] = Id, ["endpoints"] = new JArray(Endpoint(Parameters())) } + : new JObject { ["data"] = new JArray(Model()) }) }; + OpenRouterModelCatalog.TransportOverrideForTests = http; + } + + [TearDown] + public void TearDown() + { + AssetGenJobManager.ResetForTests(); + AssetGenModelCatalog.ResetForTests(true); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + [Test] + public void Discovery_ReplacesBundledModels_AndSurvivesOfflineCacheReload() + { + Assert.IsTrue(OpenRouterModelCatalog.RefreshAsync().Result); + Assert.AreEqual(Id, AssetGenModelCatalog.DefaultModelId("openrouter", "image")); + var model = AssetGenModelCatalog.ForProvider("openrouter", "image").Single(); + Assert.IsNull(model.VerifiedAt); + CollectionAssert.AreEqual(new[] { "text", "image" }, model.Modes); + OpenRouterModelCatalog.ReloadCacheForTests(); + Assert.AreEqual("cache", OpenRouterModelCatalog.Source); + int requests = http.RecordedRequests.Count; + now = now.AddHours(23); + Assert.IsTrue(OpenRouterModelCatalog.RefreshAsync().Result); + Assert.AreEqual(requests, http.RecordedRequests.Count); + http.Handler = _ => new HttpResult { Status = 500, Text = "do not persist this body" }; + now = now.AddHours(2); + Assert.IsFalse(OpenRouterModelCatalog.RefreshAsync().Result); + Assert.IsTrue(OpenRouterModelCatalog.IsStale); + Assert.IsNotNull(AssetGenModelCatalog.ForProvider("openrouter", "image").Single()); + StringAssert.Contains("500", OpenRouterModelCatalog.LastError); + StringAssert.DoesNotContain("do not persist", OpenRouterModelCatalog.LastError); + Assert.IsTrue(http.RecordedRequests.All(r => r.Method == "GET" && !r.Headers.ContainsKey("Authorization"))); + } + + [Test] + public void EmptySuccessfulCatalog_RemovesRetiredSavedModel_WithoutSubstitution() + { + string old = AssetGenPrefs.GetSelectedModel("image", "openrouter"); + AssetGenPrefs.SetSelectedModel("image", "openrouter", Id); + try + { + http.Handler = _ => Json(new JObject { ["data"] = new JArray() }); + Assert.IsTrue(OpenRouterModelCatalog.RefreshAsync(true).Result); + OpenRouterModelCatalog.ReloadCacheForTests(); + Assert.IsEmpty(AssetGenModelCatalog.ForProvider("openrouter", "image")); + Assert.Throws(() => AssetGenModelCatalog.ResolveModel("image", "openrouter", null)); + Assert.AreEqual(Id, AssetGenPrefs.GetSelectedModel("image", "openrouter")); + } + finally { AssetGenPrefs.SetSelectedModel("image", "openrouter", old); } + } + + [Test] + public void EndpointVerification_UsesDefinitiveCapabilities_AndPinsCompatibleProvider() + { + http.Handler = r => Json(r.Url.EndsWith("/endpoints") + ? new JObject { ["id"] = Id, ["endpoints"] = new JArray(Endpoint(Parameters(0, 0), "text-only"), Endpoint(Parameters(), "image-vendor")) } + : new JObject { ["data"] = new JArray(Model()) }); + var entry = OpenRouterModelCatalog.VerifyForGeneration(Id, "image", CancellationToken.None).Result; + Assert.AreEqual("image-vendor", entry.RouterProviderTag); + Assert.AreEqual("png", entry.OutputFormat); + Assert.IsTrue(http.RecordedRequests.All(r => new Uri(r.Url).Host == "openrouter.ai")); + var adapter = new OpenRouterAdapter(); + var paid = new FakeHttpTransport { Handler = _ => Json(new JObject { ["data"] = new JArray(new JObject + { ["b64_json"] = Convert.ToBase64String(new byte[] { 255, 216, 255, 1 }), ["media_type"] = "image/jpeg" }) }) }; + adapter.SubmitAsync(new ImageGenRequest { CatalogEntry = entry, Model = Id, Mode = "image", ImageUrl = "https://example.com/ref.png", Prompt = "paint" }, "test-key", paid, CancellationToken.None).GetAwaiter().GetResult(); + var request = paid.RecordedRequests.Single(); + Assert.AreEqual("https://openrouter.ai/api/v1/images", request.Url); + var body = JObject.Parse(Encoding.UTF8.GetString(request.Body)); + Assert.AreEqual("image-vendor", (string)body["provider"]["only"][0]); + Assert.IsFalse((bool)body["provider"]["allow_fallbacks"]); + Assert.AreEqual("https://example.com/ref.png", (string)body["input_references"][0]["image_url"]["url"]); + Assert.IsNull(body["messages"]); + Assert.AreEqual("jpg", adapter.PollAsync("ready", "test-key", paid, CancellationToken.None).Result.ResultExt); + Assert.Throws(() => adapter.SubmitAsync(new ImageGenRequest { CatalogEntry = entry, Model = Id, Width = 512, Height = 512 }, "test-key", paid, CancellationToken.None).GetAwaiter().GetResult()); + } + + [Test] + public void Discovery_ExcludesVectorOnly_AndDistinguishesImageRequiredModels() + { + var vector = Parameters(); vector["output_format"]["values"] = new JArray("svg"); + http.Handler = _ => Json(new JObject { ["data"] = new JArray(Model("test/vector", vector), Model(Id, Parameters(1, 1))) }); + Assert.IsTrue(OpenRouterModelCatalog.RefreshAsync(true).Result); + var entry = AssetGenModelCatalog.ForProvider("openrouter", "image").Single(); + CollectionAssert.AreEqual(new[] { "image" }, entry.Modes); + Assert.IsNull(AssetGenModelCatalog.DefaultModelId("openrouter", "image")); + } + + [Test] + public void UnavailableModel_FailsBeforePaidSubmission() + { + string key = Environment.GetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY"); + Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", "test-key"); + try + { + http.Handler = _ => Json(new JObject { ["data"] = new JArray() }); + var paid = new FakeHttpTransport(); + AssetGenJobManager.TransportOverrideForTests = paid; + var job = AssetGenJobManager.StartImageGeneration(new ImageGenRequest { Provider = "openrouter", Model = Id, Prompt = "cat" }); + var tick = typeof(AssetGenJobManager).GetMethod("Tick", BindingFlags.NonPublic | BindingFlags.Static); + for (int i = 0; i < 6; i++) tick.Invoke(null, null); + Assert.AreEqual(AssetGenJobState.Failed, job.State); + Assert.IsEmpty(paid.RecordedRequests); + } + finally { Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", key); } + } + + [UnityTest, Explicit("Reads public OpenRouter image discovery; no paid generation.")] + public IEnumerator LivePublicCatalog_AndExactEndpoint() + { + if (Environment.GetEnvironmentVariable("MCPFORUNITY_RUN_LIVE_CATALOG") != "1") Assert.Ignore("Opt-in live discovery test."); + OpenRouterModelCatalog.TransportOverrideForTests = new UnityWebRequestTransport(); + OpenRouterModelCatalog.UtcNow = () => DateTime.UtcNow; + var refresh = OpenRouterModelCatalog.RefreshAsync(true); + while (!refresh.IsCompleted) yield return null; + Assert.IsTrue(refresh.Result, OpenRouterModelCatalog.LastError); + var entries = AssetGenModelCatalog.ForProvider("openrouter", "image"); + Assert.IsNotEmpty(entries); + TestContext.WriteLine("OpenRouter: " + entries.Count + " discovered image models"); + var verify = OpenRouterModelCatalog.VerifyForGeneration(entries.First(e => e.Modes.Contains("text")).Id, "text", CancellationToken.None); + while (!verify.IsCompleted) yield return null; + Assert.IsFalse(verify.IsFaulted, verify.Exception?.ToString()); + TestContext.WriteLine("Verified " + verify.Result.Id + " via " + verify.Result.RouterProviderTag); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs.meta new file mode 100644 index 000000000..8770318f8 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterModelCatalogTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a9d0f545e5a4770a90e649b75064b0d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/asset-gen-manual-verification.md b/docs/asset-gen-manual-verification.md index f6aad0ffa..1e1108c19 100644 --- a/docs/asset-gen-manual-verification.md +++ b/docs/asset-gen-manual-verification.md @@ -25,12 +25,12 @@ genuine provider keys and an interactive Editor before shipping. - [ ] `generate_model(provider=tripo, mode=text, prompt="...", format=glb)`, poll status. - [ ] Confirm the GLB imports correctly (no missing-importer error). -## Dynamic fal model catalog +## Automatic model discovery -- [ ] Open Asset Generation and click **Refresh**: fal image/audio choices update. Other providers remain bundled and unverified. -- [ ] Run `unity-mcp asset-gen list-models --kind audio --refresh`; repeat without `--refresh` while `catalogs[].refreshing` is true. Check source and verification time. +- [ ] Open Asset Generation: search live fal image/audio/3D and OpenRouter image choices; catalogs refresh every 24 hours while open. +- [ ] Run `unity-mcp asset-gen list-models --kind model --provider fal --refresh`; repeat while `catalogs[].refreshing` is true. Use `--search`, `--mode`, `--limit` and `--offset` to browse. - [ ] Confirm failed refreshes preserve cached choices, missing models preserve the saved ID, and background refreshes preserve unsaved key input. -- [ ] Set `MCPFORUNITY_RUN_LIVE_CATALOG=1` and run `FalModelCatalogTests.LivePublicCatalog_RefreshAndExactEndpointVerification` in EditMode. It checks the live catalog without paid generation. +- [ ] Set `MCPFORUNITY_RUN_LIVE_CATALOG=1` and run both catalog fixtures' `LivePublicCatalog*` tests. They check public discovery and compatibility without paid generation. ## fal.ai (default 2D image) diff --git a/website/docs/reference/tools/asset_gen/generate_audio.md b/website/docs/reference/tools/asset_gen/generate_audio.md index 0d815545c..8a9703171 100644 --- a/website/docs/reference/tools/asset_gen/generate_audio.md +++ b/website/docs/reference/tools/asset_gen/generate_audio.md @@ -14,7 +14,7 @@ description: "Generate audio (sound effects and background music) with fal.ai mo Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in the editor's secure store (shared with image generation) and never crosses the bridge. -Use list_models for current compatible models. Omit model to use the Asset Generation tab selection. +Use list_models to discover current models; compatibility is checked before generation. Omit model to use the Asset Generation tab selection. ACTIONS: - generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the status action. Params: provider (fal), prompt, model, duration (seconds), name, output_folder. @@ -36,6 +36,10 @@ ACTIONS: | `name` | `str \| None` | — | Base name for the imported asset. | | `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. | | `job_id` | `str \| None` | — | Job id for status/cancel. | +| `search` | `str \| None` | — | Filter list_models by name, id or use case. | +| `mode` | `str \| None` | — | Filter list_models by input mode (text). | +| `limit` | `int \| None` | — | Model page size (1..200; default 50). | +| `offset` | `int \| None` | — | Model page offset (default 0). | ## Returns diff --git a/website/docs/reference/tools/asset_gen/generate_image.md b/website/docs/reference/tools/asset_gen/generate_image.md index 59fbf17b7..fe78a174f 100644 --- a/website/docs/reference/tools/asset_gen/generate_image.md +++ b/website/docs/reference/tools/asset_gen/generate_image.md @@ -20,8 +20,8 @@ ACTIONS: - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured image providers and capabilities (no key values). -- list_models: List models, modes and freshness. OpenRouter uses bundled models. -- refresh_models: Force a fal refresh. Repeat list_models while catalogs[].refreshing is true. +- list_models: Search/paginate live fal and OpenRouter models, modes and freshness. +- refresh_models: Force discovery. Repeat list_models while catalogs[].refreshing is true. ## Parameters @@ -40,6 +40,9 @@ ACTIONS: | `name` | `str \| None` | — | Base name for the imported asset. | | `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. | | `job_id` | `str \| None` | — | Job id for status/cancel. | +| `search` | `str \| None` | — | Filter list_models by name, id or use case. | +| `limit` | `int \| None` | — | Model page size (1..200; default 50). | +| `offset` | `int \| None` | — | Model page offset (default 0). | ## Returns @@ -49,9 +52,9 @@ A `dict` containing the Unity response. The exact shape depends on the action. ```json -{"action": "list_models", "provider": "fal"} +{"action": "list_models", "provider": "openrouter", "search": "flux", "limit": 20} ``` -CLI: `unity-mcp asset-gen list-models --kind image --provider fal --refresh`. +CLI: `unity-mcp asset-gen list-models --kind image --provider openrouter --refresh`. diff --git a/website/docs/reference/tools/asset_gen/generate_model.md b/website/docs/reference/tools/asset_gen/generate_model.md index 69701045e..5f17556bd 100644 --- a/website/docs/reference/tools/asset_gen/generate_model.md +++ b/website/docs/reference/tools/asset_gen/generate_model.md @@ -1,7 +1,7 @@ --- title: generate_model sidebar_label: generate_model -description: "Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project." +description: "Generate 3D models with AI providers (Tripo, Meshy, fal) and import them into the Unity project." --- # `generate_model` @@ -12,21 +12,22 @@ description: "Generate 3D models with AI providers (Tripo, Meshy) and import the ## Description -Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project. Bring-your-own-key: provider keys live in the editor's secure store and never cross the bridge. +Generate 3D models with AI providers (Tripo, Meshy, fal) and import them into the Unity project. Bring-your-own-key: provider keys live in the editor's secure store and never cross the bridge. ACTIONS: - generate: Submit a generation job (text->3D or image->3D). Returns { job_id } immediately; poll with the status action. Params: provider, mode (text|image), prompt, image_path|image_url, format (glb|fbx|obj|usdz), target_size, texture, tier, model, name, output_folder. - status: Poll an async job by job_id -> { state, progress, assetPath?, error? }. - cancel: Cancel an in-flight job by job_id. - list_providers: List configured 3D providers and capabilities (no key values). -- list_models: List bundled Tripo/Meshy models (unverified). +- list_models: Search/paginate live fal 3D models and bundled Tripo/Meshy models. +- refresh_models: Refresh fal discovery. Repeat list_models while catalogs[].refreshing is true. ## Parameters | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['generate', 'status', 'cancel', 'list_providers', 'list_models']` | yes | Action to perform. | -| `provider` | `str \| None` | — | Provider id (tripo, meshy). | +| `action` | `Literal['generate', 'status', 'cancel', 'list_providers', 'list_models', 'refresh_models']` | yes | Action to perform. | +| `provider` | `str \| None` | — | Provider id (tripo, meshy, fal). fal supports GLB output. | | `mode` | `str \| None` | — | Generation mode: text or image. | | `prompt` | `str \| None` | — | Text prompt for text->3D. | | `image_path` | `str \| None` | — | Path to a source image for image->3D. | @@ -39,6 +40,9 @@ ACTIONS: | `name` | `str \| None` | — | Base name for the imported asset. | | `output_folder` | `str \| None` | — | Destination folder under Assets/ for the import. | | `job_id` | `str \| None` | — | Job id for status/cancel. | +| `search` | `str \| None` | — | Filter list_models by name, id or use case. | +| `limit` | `int \| None` | — | Model page size (1..200; default 50). | +| `offset` | `int \| None` | — | Model page offset (default 0). | ## Returns @@ -48,9 +52,9 @@ A `dict` containing the Unity response. The exact shape depends on the action. ```json -{"action": "list_models", "provider": "meshy"} +{"action": "list_models", "provider": "fal", "mode": "image"} ``` -CLI: `unity-mcp asset-gen list-models --kind model`. +CLI: `unity-mcp asset-gen list-models --kind model --provider fal --refresh` (GLB output). diff --git a/website/docs/reference/tools/asset_gen/index.md b/website/docs/reference/tools/asset_gen/index.md index a13023ee8..9032a55bd 100644 --- a/website/docs/reference/tools/asset_gen/index.md +++ b/website/docs/reference/tools/asset_gen/index.md @@ -10,6 +10,6 @@ AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-you - **[`generate_audio`](./generate_audio.md)** — Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. - **[`generate_image`](./generate_image.md)** — Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as textures/sprites into the Unity project. -- **[`generate_model`](./generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project. +- **[`generate_model`](./generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy, fal) and import them into the Unity project. - **[`import_model`](./import_model.md)** — Import 3D models from the Sketchfab marketplace into the Unity project. - **[`import_model_file`](./import_model_file.md)** — Import a local 3D model file that already exists on disk (e.g. an FBX/OBJ/glTF exported from Blender or another DCC tool) into the Unity project. diff --git a/website/docs/reference/tools/index.md b/website/docs/reference/tools/index.md index a7466a134..1eb957045 100644 --- a/website/docs/reference/tools/index.md +++ b/website/docs/reference/tools/index.md @@ -20,7 +20,7 @@ Animator control & AnimationClip creation AI asset generation – 3D model gen/import, 2D image gen & audio gen (bring-your-own-key) - **[`generate_audio`](./asset_gen/generate_audio.md)** — Generate audio (sound effects and background music) with fal.ai models and import them as AudioClips into the Unity project. - **[`generate_image`](./asset_gen/generate_image.md)** — Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as textures/sprites into the Unity project. -- **[`generate_model`](./asset_gen/generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy) and import them into the Unity project. +- **[`generate_model`](./asset_gen/generate_model.md)** — Generate 3D models with AI providers (Tripo, Meshy, fal) and import them into the Unity project. - **[`import_model`](./asset_gen/import_model.md)** — Import 3D models from the Sketchfab marketplace into the Unity project. - **[`import_model_file`](./asset_gen/import_model_file.md)** — Import a local 3D model file that already exists on disk (e.g. an FBX/OBJ/glTF exported from Blender or another DCC tool) into the Unity project. From 93bd9740881201b5ffba324f8fe5ee79ad4455ba Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:46:14 -0400 Subject: [PATCH 4/7] Bound recursive nullable schema parsing --- .../Editor/Services/AssetGen/FalModelSchema.cs | 18 +++++++++++------- .../EditMode/AssetGen/FalModelCatalogTests.cs | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs index dc5bd35df..2ee7c19c7 100644 --- a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs @@ -183,11 +183,12 @@ private static bool AcceptsOne(JObject schema) => schema != null && ((float?)schema["minimum"] ?? 1f) <= 1f && ((float?)schema["maximum"] ?? 1f) >= 1f && schema["enum"] == null; - private static bool SupportsDimensions(JObject api, JToken token) + private static bool SupportsDimensions(JObject api, JToken token, int depth = 0) { + if (depth >= 8) return false; var schema = Resolve(api, token); if (schema == null) return false; - if (schema["anyOf"] is JArray options) return options.Any(option => SupportsDimensions(api, option)); + if (schema["anyOf"] is JArray options) return options.Any(option => SupportsDimensions(api, option, depth + 1)); return (string)Resolve(api, schema["properties"]?["width"])?["type"] == "integer" && (string)Resolve(api, schema["properties"]?["height"])?["type"] == "integer"; } @@ -195,20 +196,23 @@ private static bool SupportsDimensions(JObject api, JToken token) private static bool HasFile(JObject api, JObject output, string field) => (string)Resolve(api, Resolve(api, output?["properties"]?[field])?["properties"]?["url"])?["type"] == "string"; - private static JObject Numeric(JObject api, JToken token) + private static JObject Numeric(JObject api, JToken token, int depth = 0) { + if (depth >= 8) return null; var schema = Resolve(api, token); if (schema == null) return null; string type = (string)schema["type"]; if (type == "integer" || type == "number") return schema; - return (schema["anyOf"] as JArray)?.Select(option => Numeric(api, option)).FirstOrDefault(option => option != null); + return (schema["anyOf"] as JArray)?.Select(option => Numeric(api, option, depth + 1)).FirstOrDefault(option => option != null); } - private static JObject Resolve(JObject api, JToken token) + private static JObject Resolve(JObject api, JToken token, int depth = 0) { + if (depth >= 8) return null; var schema = token as JObject; - for (int depth = 0; schema?["$ref"] != null && depth < 8; depth++) + while (schema?["$ref"] != null) { + if (++depth >= 8) return null; string reference = (string)schema["$ref"]; const string prefix = "#/components/schemas/"; if (!reference.StartsWith(prefix, StringComparison.Ordinal)) return null; @@ -217,7 +221,7 @@ private static JObject Resolve(JObject api, JToken token) if (schema?["anyOf"] is JArray options) { var nonNull = options.Where(option => (string)option["type"] != "null").ToArray(); - if (nonNull.Length == 1) return Resolve(api, nonNull[0]); + if (nonNull.Length == 1) return Resolve(api, nonNull[0], depth + 1); } return schema?["$ref"] == null ? schema : null; } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs index e1d38c0df..b561dfc35 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs @@ -388,6 +388,20 @@ public void SchemaGate_RejectsUnsupportedOutputAndUnsafeEndpointId() Assert.IsNull(FalModelSchema.Parse(Endpoint("https://attacker.invalid/music"), "audio", now.ToString("O"))); } + [Test] + public void RecursiveNullableAndUnionSchemas_DoNotOverflowTheEditorStack() + { + var model = Endpoint(Music); + var schemas = model["openapi"]["components"]["schemas"]; + schemas["File"] = new JObject { ["anyOf"] = new JArray(new JObject { ["$ref"] = "#/components/schemas/File" }, new JObject { ["type"] = "null" }) }; + Assert.IsNull(FalModelSchema.Parse(model, "audio", now.ToString("O"))); + model = Endpoint(Music); + schemas = model["openapi"]["components"]["schemas"]; + schemas["Loop"] = new JObject { ["anyOf"] = new JArray(new JObject { ["$ref"] = "#/components/schemas/Loop" }, new JObject { ["type"] = "string" }) }; + Input(model)["properties"]["duration"] = new JObject { ["$ref"] = "#/components/schemas/Loop" }; + Assert.IsNull(FalModelSchema.Parse(model, "audio", now.ToString("O"))); + } + [Test] public void ImageSchema_ChoosesImportableOutputFormat_AndRejectsVectorOnlyModels() { From 9b9393e12388692922a5090cc8e8bd4e2346ea61 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:25:29 -0400 Subject: [PATCH 5/7] Fix asset model dropdowns: no slash submenus, capped menus, one fal key row - Editor DropdownFields open a native GenericMenu that turns every '/' in a model id into a submenu; menu items now show U+2215 instead (passed via the ctor, since formatListItemCallback is not public before Unity 6). - Cap each model menu at 25 entries, keep the saved selection, and point to Search for the rest of large live catalogs. - The fal 3D row reuses the 2D fal key and toggle like the audio row, instead of a second key field and an out-of-sync Enabled toggle. - Rebuild model controls only on catalog Changed events, scoped by provider, and keep failed compatibility checks visible across rebuilds. - Skip the selector for providers with no models (Sketchfab showed "No models found") and show verification state only for fal/OpenRouter. --- .../Components/AssetGen/McpAssetGenSection.cs | 149 +++++++++++++----- .../EditMode/AssetGen/FalModelCatalogTests.cs | 58 +++++++ .../Windows_Characterization.cs | 12 +- 3 files changed, 173 insertions(+), 46 deletions(-) diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 33529d17f..bc202aae4 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -26,14 +26,19 @@ public class McpAssetGenSection { // Fixed provider lists. Each Id is both the SecureKeyStore key and the // AssetGenPrefs enable-flag id. All model/marketplace providers below emit GLB. + // fal's key and enable toggle live on its 2D Images row; the 3D row only picks a model. private static readonly (string Id, string Label)[] ModelProviders = { ("tripo", "Tripo"), ("meshy", "Meshy"), - ("fal", "fal (shared key)"), + ("fal", "fal (3D)"), ("sketchfab", "Sketchfab"), }; + // Editor dropdowns open a native menu with no search, so long catalogs are capped and + // the per-provider search field reaches the remaining models. + internal const int MenuLimit = 25; + private static readonly (string Id, string Label)[] ImageProviders = { ("fal", "fal"), @@ -54,6 +59,8 @@ private static readonly (string Id, string Label)[] ImageProviders = private readonly List<(string Id, Toggle Toggle)> modelEnableToggles = new(); private readonly List<(VisualElement Container, string Kind, string Provider)> modelControls = new(); private readonly Dictionary searches = new(); + // Last failed compatibility check per kind/provider, kept so a catalog rebuild cannot hide it. + private readonly Dictionary verifyErrors = new(); public VisualElement Root { get; private set; } @@ -63,8 +70,13 @@ public McpAssetGenSection(VisualElement root) CacheUIElements(); InitializeUI(); RegisterCallbacks(); - Root.RegisterCallback(_ => SubscribeCatalogs()); - Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnCatalogChanged; OpenRouterModelCatalog.Changed -= OnRouterChanged; }); + Root.RegisterCallback(_ => + { + SubscribeCatalogs(); + // Catch up on catalog changes that fired while the tab was detached. + RebuildModelControls(null, null); + }); + Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnFalChanged; OpenRouterModelCatalog.Changed -= OnRouterChanged; }); if (Root.panel != null) SubscribeCatalogs(); Root.schedule.Execute(() => { if (!modelControls.Any(c => FalModelCatalog.IsRefreshing(c.Kind)) && !OpenRouterModelCatalog.IsRefreshing) _ = RefreshCatalog(false); }).Every(60000); _ = RefreshCatalog(false); @@ -72,13 +84,15 @@ public McpAssetGenSection(VisualElement root) private void SubscribeCatalogs() { - FalModelCatalog.Changed -= OnCatalogChanged; - FalModelCatalog.Changed += OnCatalogChanged; + FalModelCatalog.Changed -= OnFalChanged; + FalModelCatalog.Changed += OnFalChanged; OpenRouterModelCatalog.Changed -= OnRouterChanged; OpenRouterModelCatalog.Changed += OnRouterChanged; } - private void OnRouterChanged() => OnCatalogChanged("image"); + private void OnFalChanged(string kind) => RebuildModelControls(kind, "fal"); + + private void OnRouterChanged() => RebuildModelControls("image", "openrouter"); private void CacheUIElements() { @@ -175,10 +189,10 @@ private async Task RefreshCatalog(bool force) if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, "Checking model catalogs…", true); try { + // A committed refresh raises Changed, which rebuilds only the affected model controls; + // unchanged catalogs leave the rows (and any unsaved API-key input) alone. await Task.WhenAll(FalModelCatalog.RefreshAsync("image", force), FalModelCatalog.RefreshAsync("audio", force), FalModelCatalog.RefreshAsync("model", force), OpenRouterModelCatalog.RefreshAsync(force)); - // Only replace model controls: an automatic refresh must preserve unsaved API-key input. - OnCatalogChanged(null); string error = FalModelCatalog.LastError("image") ?? FalModelCatalog.LastError("audio") ?? FalModelCatalog.LastError("model") ?? OpenRouterModelCatalog.LastError; string verified = FalModelCatalog.VerifiedAt("audio") ?? FalModelCatalog.VerifiedAt("image"); string when = DateTime.TryParse(verified, out var time) ? time.ToLocalTime().ToString("g") : "unknown"; @@ -188,11 +202,13 @@ await Task.WhenAll(FalModelCatalog.RefreshAsync("image", force), FalModelCatalog finally { refreshButton?.SetEnabled(true); } } - private void OnCatalogChanged(string kind) + /// Rebuilds live-catalog model controls; a null kind or provider matches all. + private void RebuildModelControls(string kind, string provider) { foreach (var control in modelControls.ToArray()) { - if (control.Provider != "fal" && control.Provider != "openrouter" || kind != null && control.Kind != kind) continue; + if (control.Provider != "fal" && control.Provider != "openrouter") continue; + if (kind != null && control.Kind != kind || provider != null && control.Provider != provider) continue; control.Container.Clear(); PopulateModelDropdown(control.Container, control.Kind, control.Provider); } @@ -222,6 +238,13 @@ private void BuildProviderRows() var modelPanel = AddCategoryPanel("3D Models"); foreach (var provider in ModelProviders) { + if (provider.Id == "fal") + { + AddSharedFalRow(modelPanel, "model", provider.Label); + // No toggle of its own: the glTFast notice reads the 2D fal row's enable pref. + modelEnableToggles.Add((provider.Id, null)); + continue; + } var toggle = AddProviderRow(modelPanel, provider.Id, provider.Label, "model"); modelEnableToggles.Add((provider.Id, toggle)); } @@ -233,7 +256,7 @@ private void BuildProviderRows() } var audioPanel = AddCategoryPanel("Sound (fal.ai)"); - AddAudioRow(audioPanel); + AddSharedFalRow(audioPanel, "audio", "fal (audio)"); AddBlenderHandoffRow(); } @@ -439,20 +462,30 @@ void SaveKeyFromField() /// private void AddModelDropdown(VisualElement parent, string kind, string providerId) { - if (providerId == "fal" || providerId == "openrouter") + bool live = providerId == "fal" || providerId == "openrouter"; + // Bundled-only providers with no models (the Sketchfab marketplace) get no selector at all. + if (!live && AssetGenModelCatalog.ForProvider(providerId, kind).Count == 0) return; + if (live) { - var search = new TextField("Search models") { name = "model-search-" + kind + "-" + providerId }; + // Same .setting-row / .setting-label layout as the Model row below, so both align. + var searchRow = new VisualElement(); + searchRow.AddToClassList("setting-row"); + var searchLabel = new Label("Search"); + searchLabel.AddToClassList("setting-label"); + searchRow.Add(searchLabel); + + var search = new TextField { name = "model-search-" + kind + "-" + providerId }; + search.AddToClassList("setting-dropdown-inline"); + search.tooltip = "Filter this provider's models by name, id or use case."; string key = kind + "/" + providerId; search.SetValueWithoutNotify(searches.TryGetValue(key, out var term) ? term : ""); - parent.Add(search); search.RegisterValueChangedCallback(evt => { searches[key] = evt.newValue ?? ""; - var control = modelControls.FirstOrDefault(c => c.Kind == kind && c.Provider == providerId); - if (control.Container == null) return; - control.Container.Clear(); - PopulateModelDropdown(control.Container, kind, providerId); + RebuildModelControls(kind, providerId); }); + searchRow.Add(search); + parent.Add(searchRow); } var container = new VisualElement(); parent.Add(container); @@ -463,10 +496,15 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider private void PopulateModelDropdown(VisualElement parent, string kind, string providerId) { var all = AssetGenModelCatalog.ForProvider(providerId, kind); + string key = kind + "/" + providerId; string selectedId = AssetGenPrefs.GetSelectedModel(kind, providerId); if (string.IsNullOrEmpty(selectedId)) selectedId = AssetGenModelCatalog.DefaultModelId(providerId, kind); - string term = searches.TryGetValue(kind + "/" + providerId, out var query) ? query : ""; - IReadOnlyList models = all.Where(m => m.Id == selectedId || (m.Id + " " + m.Label + " " + m.UseCase).IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + string term = searches.TryGetValue(key, out var query) ? query : ""; + var matches = all.Where(m => (m.Id + " " + m.Label + " " + m.UseCase).IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + // Catalog order puts bundled and eagerly verified models first, so the cap keeps those. + var models = matches.Take(MenuLimit).ToList(); + ModelEntry selected = all.FirstOrDefault(model => model.Id == selectedId); + if (selected != null && !models.Contains(selected)) models.Insert(0, selected); if (models.Count == 0) { parent.Add(new Label("No models found. Clear the search or refresh the catalog.")); @@ -476,9 +514,9 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro var choices = new List(); foreach (ModelEntry m in models) choices.Add(m.Label + " (" + m.Id + ")"); - ModelEntry selected = models.FirstOrDefault(model => model.Id == selectedId); - int selectedIndex = selected == null ? choices.Count : models.ToList().IndexOf(selected); - if (selected == null) choices.Add("Saved model unavailable — choose another (" + selectedId + ")"); + int selectedIndex = selected == null ? choices.Count : models.IndexOf(selected); + if (selected == null) + choices.Add(string.IsNullOrEmpty(selectedId) ? "Choose a model" : "Saved model unavailable — choose another (" + selectedId + ")"); // Lay the dropdown out like the Format row: a horizontal .setting-row (align-items:center, // min-height:24px) with a .setting-label + a label-less DropdownField. Adding the dropdown @@ -490,7 +528,8 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro modelLabel.AddToClassList("setting-label"); dropdownRow.Add(modelLabel); - var dropdown = new DropdownField(choices, 0); + // The list-item callback goes through the ctor: the property is not public before Unity 6. + var dropdown = new DropdownField(choices, 0, null, MenuItemText); dropdown.AddToClassList("setting-dropdown-inline"); dropdown.tooltip = "The model generate_* uses for this provider when no explicit model is passed."; dropdown.SetValueWithoutNotify(choices[selectedIndex]); @@ -498,6 +537,14 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro parent.Add(dropdownRow); + if (matches.Count > MenuLimit) + { + var more = new Label($"The menu shows {MenuLimit} of {matches.Count} models. Use Search to find the others."); + more.AddToClassList("help-text"); + more.style.whiteSpace = WhiteSpace.Normal; + parent.Add(more); + } + var meta = new Label(); meta.AddToClassList("help-text"); meta.style.whiteSpace = WhiteSpace.Normal; @@ -512,7 +559,9 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro { UpdateModelMeta(meta, selected); UpdateModelCaveat(caveat, selected); + if (verifyErrors.TryGetValue(key, out var failure) && failure.Id == selected.Id) ShowVerifyError(meta, failure.Error); } + else if (string.IsNullOrEmpty(selectedId)) meta.text = "Choose a model before generating."; else meta.text = "Your saved selection is preserved. Choose an available model before generating."; dropdown.RegisterValueChangedCallback(evt => @@ -521,36 +570,53 @@ private void PopulateModelDropdown(VisualElement parent, string kind, string pro if (index < 0 || index >= models.Count) return; ModelEntry picked = models[index]; AssetGenPrefs.SetSelectedModel(kind, providerId, picked.Id); + verifyErrors.Remove(key); UpdateModelMeta(meta, picked); UpdateModelCaveat(caveat, picked); if (providerId == "fal" || providerId == "openrouter") _ = VerifySelection(picked, meta); }); } - private static async Task VerifySelection(ModelEntry picked, Label meta) + private async Task VerifySelection(ModelEntry picked, Label meta) { + string key = picked.Kind + "/" + picked.Provider; meta.text = "Checking compatibility…"; try { - string key = null; - if (picked.Provider == "fal") try { SecureKeyStore.Current.TryGet("fal", out key); } catch { } + string apiKey = null; + if (picked.Provider == "fal") try { SecureKeyStore.Current.TryGet("fal", out apiKey); } catch { } string mode = picked.Modes?.FirstOrDefault() ?? "text"; var verified = picked.Provider == "fal" - ? await FalModelCatalog.VerifyForGeneration(picked.Id, picked.Kind, mode, CancellationToken.None, key) + ? await FalModelCatalog.VerifyForGeneration(picked.Id, picked.Kind, mode, CancellationToken.None, apiKey) : await OpenRouterModelCatalog.VerifyForGeneration(picked.Id, mode, CancellationToken.None); if (AssetGenPrefs.GetSelectedModel(picked.Kind, picked.Provider) == picked.Id) UpdateModelMeta(meta, verified); } catch (Exception error) { - if (AssetGenPrefs.GetSelectedModel(picked.Kind, picked.Provider) == picked.Id) meta.text = SecretRedactor.Scrub(error.Message); + if (AssetGenPrefs.GetSelectedModel(picked.Kind, picked.Provider) != picked.Id) return; + string message = "Compatibility check failed: " + SecretRedactor.Scrub(error.Message); + verifyErrors[key] = (picked.Id, message); + ShowVerifyError(meta, message); } } /// - /// Audio row: no enable toggle and no key field — audio reuses the single fal key owned by - /// the Image "fal" row. Surfaces that key's presence and a fal-audio model dropdown. + /// Editor dropdowns open a native GenericMenu, which reads every '/' in a model id as a + /// submenu separator. The menu shows a look-alike slash; the field keeps the real text. + /// + internal static string MenuItemText(string choice) => choice.Replace('/', '\u2215'); + + private static void ShowVerifyError(Label meta, string message) + { + meta.text = message; + meta.AddToClassList("warning-banner-text"); + } + + /// + /// fal audio / 3D row: no enable toggle and no key field — these kinds reuse the single fal + /// key owned by the Image "fal" row. Surfaces that key's presence and a model dropdown. /// - private void AddAudioRow(VisualElement parent) + private void AddSharedFalRow(VisualElement parent, string kind, string displayName) { var row = new VisualElement(); row.style.marginBottom = 8; @@ -558,14 +624,14 @@ private void AddAudioRow(VisualElement parent) row.style.borderBottomWidth = 1; row.style.borderBottomColor = new Color(0.3f, 0.3f, 0.3f, 0.3f); - // Header: name + shared-key status inline to its right. No key field — audio reuses the - // fal key owned by the 2D fal row. + // Header: name + shared-key status inline to its right. No key field — the fal key is + // owned by the 2D fal row. var header = new VisualElement(); header.style.flexDirection = FlexDirection.Row; header.style.alignItems = Align.Center; header.style.marginBottom = 2; - var nameLabel = new Label("fal (audio)"); + var nameLabel = new Label(displayName); nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; nameLabel.style.flexShrink = 0; header.Add(nameLabel); @@ -580,15 +646,15 @@ private void AddAudioRow(VisualElement parent) row.Add(header); - AddModelDropdown(row, "audio", "fal"); + AddModelDropdown(row, kind, "fal"); parent.Add(row); } /// - /// The fal key is shared with the audio row, whose "key present" status is snapshotted at - /// build time. When the 2D fal key is saved/cleared, schedule a full rebuild so the audio row - /// reflects it without a manual Refresh. Deferred so we don't destroy the element whose + /// The fal key is shared with the audio and 3D rows, whose "key present" status is snapshotted at + /// build time. When the 2D fal key is saved/cleared, schedule a full rebuild so those rows + /// reflect it without a manual Refresh. Deferred so we don't destroy the element whose /// callback is still running. /// private void RebuildIfSharedKey(string id) @@ -607,10 +673,13 @@ private static void UpdateModelMeta(Label label, ModelEntry m) // Lyria advertises a max but takes no duration input, so showing a hint would mislead. if (m.MaxDurationSeconds > 0f && !string.IsNullOrEmpty(m.DurationField)) parts.Add($"≤{m.MaxDurationSeconds:0}s"); if (m.Loopable) parts.Add("loopable"); - parts.Add(m.VerifiedAt != null ? "compatibility verified" : m.FromRefresh ? "discovered · checked before generation" : "bundled · unverified"); + // Only live-catalog providers have a verification state; Tripo/Meshy are bundled by design. + if (m.Provider == "fal" || m.Provider == "openrouter") + parts.Add(m.VerifiedAt != null ? "compatibility verified" : m.FromRefresh ? "discovered · checked before generation" : "bundled · checked before generation"); if (!string.IsNullOrEmpty(m.LicenseType)) parts.Add("license: " + m.LicenseType); label.text = string.Join(" · ", parts); label.tooltip = m.ModelUrl ?? m.Id; + label.RemoveFromClassList("warning-banner-text"); } private static void UpdateModelCaveat(Label label, ModelEntry m) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs index b561dfc35..aeedcef1b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs @@ -348,6 +348,64 @@ public void MissingSavedSelection_IsPreserved_AndGenerationErrors() Assert.IsTrue(root.Query().ToList().Any(dropdown => dropdown.value?.Contains("Saved model unavailable") == true)); } + [Test] + public void LongCatalog_ModelMenuIsCapped_KeepsSelection_AndSearchReachesTheRest() + { + Serve(Enumerable.Range(0, 40).Select(i => Endpoint("test/music-" + i.ToString("D2"))).ToArray()); + Assert.IsTrue(Refresh()); + AssetGenPrefs.SetSelectedModel("audio", "fal", "test/music-39"); + var root = new VisualElement(); + root.Add(new VisualElement { name = "assetgen-providers-container" }); + var section = new McpAssetGenSection(root); + DropdownField AudioMenu() => root.Query().ToList().Single(d => d.choices.Any(c => c.Contains("test/music-"))); + + var menu = AudioMenu(); + Assert.AreEqual(McpAssetGenSection.MenuLimit + 1, menu.choices.Count, "The saved selection beyond the cap stays selectable."); + StringAssert.Contains("test/music-39", menu.value); + Assert.IsTrue(menu.choices.All(c => !McpAssetGenSection.MenuItemText(c).Contains("/")), "GenericMenu turns every '/' into a submenu."); + Assert.IsTrue(root.Query