diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index e9fa9e674..bb4b30515 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(); @@ -102,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, @@ -132,7 +141,20 @@ 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.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), ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)), Transport = transport, @@ -160,7 +182,15 @@ 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.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), ImportFn = ImportOverrideForTests ?? AudioImportPipeline.ImportInto, Transport = transport, @@ -482,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)) @@ -597,6 +634,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..5e7f33cb1 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -31,15 +31,31 @@ 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; + public string[] Modes; + public string ModelOutputField; + public string TextureField; + public string RouterProviderTag; + public Newtonsoft.Json.Linq.JObject RouterParameters; } /// - /// 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 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 { @@ -57,49 +73,53 @@ 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. - 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; + if (Eq(provider, "openrouter") && Eq(kind, "image") && OpenRouterModelCatalog.TryGet(out var images)) return images; + 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(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 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 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. /// public static string ResolveModel(string kind, string provider, string requested) @@ -107,11 +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); + 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 current {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); + 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 new file mode 100644 index 000000000..2d03eca3f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs @@ -0,0 +1,393 @@ +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 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 EagerVerificationLimit = 5; + // Guards the editor thread against a runaway cache file. The live fal catalog is ~0.6 MB + // (~860 bytes per model), so this leaves ~25x headroom before reloads stop using the cache. + internal const long MaxCacheBytes = 16 * 1024 * 1024; + 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 = 2; + 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" && 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); + 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.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 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) + .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"]; + // 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)) + { + 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; + entry.Modes = new[] { "text", "image" }; + } + } + 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 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) + { + var cursors = new HashSet(); + string cursor = null; + for (int page = 0; page < 50; page++) + { + 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."); + } + } + return models; + } + + private static async Task> Details(IEnumerable ids, IHttpTransport http, CancellationToken ct, string apiKey = null, bool foreground = false) + { + 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))); + var batch = wanted.Skip(offset).Take(5).ToArray(); + 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, foreground: foreground); + // fal can return a batch-level 404 when even one endpoint is missing. + // Check individual IDs so one retired alias cannot hide active models. + if (((JArray)json["models"]).Count == 0 && batch.Length > 1 && cursor == null) + { + foreach (string id in batch) + foreach (var item in await Details(new[] { id }, http, ct, apiKey, foreground)) result[item.Key] = item.Value; + break; + } + 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; + } + + /// + /// Background refreshes take turns through . A foreground request + /// (a generation or selection check) skips that queue so it never waits behind a full catalog + /// refresh; without a key it still keeps the public pacing interval. + /// + private static async Task Get(string url, IHttpTransport http, CancellationToken ct, string apiKey = null, bool allowMissing = false, bool foreground = false) + { + ProviderHttp.RequireHost(url, "api.fal.ai", apiKey, "fal catalog"); + HttpResult response = null; + for (int attempt = 0; attempt < 3; attempt++) + { + if (!foreground) await RequestGate.WaitAsync(ct); + try + { + TimeSpan wait = nextRequestAt - UtcNow(); + if (wait > TimeSpan.Zero && (!foreground || string.IsNullOrEmpty(apiKey))) 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 { if (!foreground) 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(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, foreground: true); + 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" && !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."); + entry.EditModelId = edit.Id; + entry.ImageInputField = edit.ImageInputField; + 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; + } + + 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)) return; + if (new FileInfo(CachePath).Length > MaxCacheBytes) + { + McpLog.Warn($"fal model cache exceeds {MaxCacheBytes / (1024 * 1024)} MB and was ignored; models are re-fetched after each reload."); + return; + } + var cache = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); + if (cache?.Version != 2 || cache.Snapshots == null) return; + foreach (var snapshot in cache.Snapshots) + { + 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; + 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..2ee7c19c7 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs @@ -0,0 +1,229 @@ +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 cannot pass preflight; metadata discovery alone never authorizes a paid submit. + /// + 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; + 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; + } + + 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 "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) + { + 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 (!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; + 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 && !imageInputRequired || 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.", + 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"]), + }; + + 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 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"; + 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 (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") + { + 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 || 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; + } + 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, 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, depth + 1)); + 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, 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, depth + 1)).FirstOrDefault(option => option != null); + } + + private static JObject Resolve(JObject api, JToken token, int depth = 0) + { + if (depth >= 8) return null; + var schema = token as JObject; + 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; + 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], depth + 1); + } + 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/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..92e2b79ea --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs @@ -0,0 +1,199 @@ +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.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)) return; + if (new FileInfo(CachePath).Length > FalModelCatalog.MaxCacheBytes) + { + McpLog.Warn($"OpenRouter model cache exceeds {FalModelCatalog.MaxCacheBytes / (1024 * 1024)} MB and was ignored; models are re-fetched after each reload."); + 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 35ec7ce65..a6e2ab1fa 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,22 @@ 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(); + 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; 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,9 +57,19 @@ 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 }; + 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 @@ -77,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. @@ -85,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; @@ -131,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/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/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 1e8d0f9e1..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; @@ -48,6 +49,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 +71,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..13dc3a177 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,51 @@ 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" || 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 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.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 = 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. 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/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..e5e1dc150 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -30,9 +30,11 @@ 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 "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."); + 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 331949303..bf08d2ce4 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -1,5 +1,8 @@ using System; 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; @@ -25,13 +28,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 (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"), @@ -51,6 +60,10 @@ 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(); + 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; } @@ -60,8 +73,30 @@ public McpAssetGenSection(VisualElement root) CacheUIElements(); InitializeUI(); RegisterCallbacks(); + 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); } + private void SubscribeCatalogs() + { + FalModelCatalog.Changed -= OnFalChanged; + FalModelCatalog.Changed += OnFalChanged; + OpenRouterModelCatalog.Changed -= OnRouterChanged; + OpenRouterModelCatalog.Changed += OnRouterChanged; + } + + private void OnFalChanged(string kind) => RebuildModelControls(kind, "fal"); + + private void OnRouterChanged() => RebuildModelControls("image", "openrouter"); + private void CacheUIElements() { providersContainer = Root.Q("assetgen-providers-container"); @@ -130,22 +165,18 @@ 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."; + "Refresh fal image, sound and 3D models, and OpenRouter images."; 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); } /// @@ -156,6 +187,38 @@ public void Refresh() { SyncFromPrefs(); blenderPanel?.Refresh(); + _ = RefreshCatalog(false); + } + + private async Task RefreshCatalog(bool force) + { + refreshButton?.SetEnabled(false); + 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)); + 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 : "Catalogs checked " + when + " · refreshed automatically every 24 hours"; + if (refreshStatusLabel != null) SetStatus(refreshStatusLabel, label, error == null); + } + finally { refreshButton?.SetEnabled(true); } + } + + /// 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") continue; + if (kind != null && control.Kind != kind || provider != null && control.Provider != provider) continue; + control.Container.Clear(); + PopulateModelDropdown(control.Container, control.Kind, control.Provider); + } } /// Rebuild the provider rows and reflect current prefs into the fields. @@ -177,10 +240,18 @@ private void BuildProviderRows() providersContainer.Clear(); modelEnableToggles.Clear(); + modelControls.Clear(); 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)); } @@ -192,7 +263,7 @@ private void BuildProviderRows() } var audioPanel = AddCategoryPanel("Sound (fal.ai)"); - AddAudioRow(audioPanel); + AddSharedFalRow(audioPanel, "audio", "fal (audio)"); } /// @@ -360,25 +431,62 @@ void SaveKeyFromField() /// private void AddModelDropdown(VisualElement parent, string kind, string providerId) { - IReadOnlyList models = AssetGenModelCatalog.ForProvider(providerId, kind); - if (models.Count == 0) return; - - var choices = new List(); - foreach (ModelEntry m in models) choices.Add(m.Label); + 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) + { + // 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 : ""); + search.RegisterValueChangedCallback(evt => + { + searches[key] = evt.newValue ?? ""; + RebuildModelControls(kind, providerId); + }); + searchRow.Add(search); + parent.Add(searchRow); + } + 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) + { + 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); - ModelEntry selected = AssetGenModelCatalog.Find(selectedId); - if (selected == null) + 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) { - // 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); + 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 + ")"); + + 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 // straight into the column row instead makes flex-grow expand it vertically into a huge box. @@ -389,14 +497,23 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider 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(selected.Label); + dropdown.SetValueWithoutNotify(choices[selectedIndex]); dropdownRow.Add(dropdown); 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; @@ -407,24 +524,69 @@ private void AddModelDropdown(VisualElement parent, string kind, string provider caveat.style.whiteSpace = WhiteSpace.Normal; parent.Add(caveat); - UpdateModelMeta(meta, selected); + // A null selection hides the caveat box instead of leaving it empty. UpdateModelCaveat(caveat, selected); + if (selected != null) + { + UpdateModelMeta(meta, 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 => { - 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); + verifyErrors.Remove(key); UpdateModelMeta(meta, picked); UpdateModelCaveat(caveat, picked); + if (providerId == "fal" || providerId == "openrouter") _ = VerifySelection(picked, meta); }); } + private async Task VerifySelection(ModelEntry picked, Label meta) + { + string key = picked.Kind + "/" + picked.Provider; + meta.text = "Checking compatibility…"; + try + { + 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, 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) return; + string message = "Compatibility check failed: " + SecretRedactor.Scrub(error.Message); + verifyErrors[key] = (picked.Id, message); + ShowVerifyError(meta, message); + } + } + + /// + /// 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"); + } + /// - /// 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. + /// 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; @@ -432,14 +594,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); @@ -454,15 +616,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) @@ -471,13 +633,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; @@ -488,7 +643,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"); + // 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/Server/src/cli/commands/asset_gen.py b/Server/src/cli/commands/asset_gen.py index 99b41d77b..a0cda380c 100644 --- a/Server/src/cli/commands/asset_gen.py +++ b/Server/src/cli/commands/asset_gen.py @@ -28,8 +28,34 @@ 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 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, 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. + Discovered entries are checked for compatibility before generation. + """ + 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 04466f837..7da6f375b 100644 --- a/Server/src/services/tools/generate_audio.py +++ b/Server/src/services/tools/generate_audio.py @@ -22,17 +22,17 @@ "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 " - "MCP for Unity -> Asset Generation tab.\n\n" + "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 " "status action. Params: provider (fal), prompt, model, duration (seconds), 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 audio providers and capabilities (no key values)." + "- list_providers: List configured audio providers and capabilities (no key values).\n" + "- 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", @@ -41,17 +41,21 @@ ) 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, 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 5ba96666c..81be15169 100644 --- a/Server/src/services/tools/generate_image.py +++ b/Server/src/services/tools/generate_image.py @@ -30,7 +30,9 @@ "- 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: 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", @@ -39,7 +41,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 +49,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, @@ -56,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) @@ -73,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 0befe52a3..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" @@ -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: 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", @@ -38,10 +40,10 @@ ) async def generate_model( 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 (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, @@ -55,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) @@ -73,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 new file mode 100644 index 000000000..e31bfec7d --- /dev/null +++ b/Server/tests/test_asset_gen_catalog.py @@ -0,0 +1,86 @@ +"""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"), + ("model", "refresh_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", "--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 "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 ae073c57f..e4a22e56e 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(); @@ -88,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/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..64c001273 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalModelCatalogTests.cs @@ -0,0 +1,661 @@ +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" } } + : 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) + { + 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"] = 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 + { + ["/" + 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 = 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()); + }; + } + 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() + { + 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)); + Assert.IsFalse(root.Query