chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Security;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter
/// (model: tripo/meshy; image: fal/openrouter; marketplace: sketchfab); unknown ids throw
/// <see cref="NotSupportedException"/>. <see cref="List"/> advertises providers and reports
/// <c>Configured</c> existence only — never a key value.
/// </summary>
public static class AssetGenProviders
{
public static IModelProviderAdapter Model(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "tripo":
return new TripoAdapter();
case "meshy":
return new MeshyAdapter();
default:
throw new NotSupportedException($"Unknown model provider '{id}'.");
}
}
public static IImageProviderAdapter Image(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "fal":
return new FalAdapter();
case "openrouter":
return new OpenRouterAdapter();
default:
throw new NotSupportedException($"Unknown image provider '{id}'.");
}
}
public static IMarketplaceProviderAdapter Marketplace(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "sketchfab":
return new SketchfabAdapter();
default:
throw new NotSupportedException($"Unknown marketplace provider '{id}'.");
}
}
public static IReadOnlyList<ProviderInfo> List()
{
return new List<ProviderInfo>
{
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 = "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" } },
};
}
private static bool IsConfigured(string id)
{
try { return SecureKeyStore.Current.Has(id); }
catch { return false; }
}
/// <summary>
/// Standard "no key" message: points the user at the Asset Generation tab and the env override.
/// Shared by the asset-gen tools and the job manager so the wording stays in one place.
/// </summary>
public static string MissingKeyMessage(string provider)
=> $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY).";
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ab11255ce314c7193df3bca43286f83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,162 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header
/// "Authorization: Key &lt;key&gt;"), polls the request's status_url, then fetches the result and
/// returns the first image URL for the job manager to download.
/// </summary>
public sealed class FalAdapter : IImageProviderAdapter
{
private const string QueueBase = "https://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).
private const string DefaultModel = "fal-ai/flux-2";
public string Id => "fal";
public async Task<string> 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));
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
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 };
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";
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body["image_urls"] = new JArray(imageRef);
}
else
{
url = QueueBase + model;
}
// 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)
body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height };
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Key " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "submit");
// Prefer response_url; fall back to building it from request_id.
string responseUrl = json["response_url"]?.ToString();
if (string.IsNullOrEmpty(responseUrl))
{
string requestId = json["request_id"]?.ToString();
if (string.IsNullOrEmpty(requestId))
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;
}
return responseUrl;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
string responseUrl = providerJobId;
var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" };
statusSpec.Headers["Authorization"] = "Key " + apiKey;
HttpResult statusRes = await http.SendAsync(statusSpec, ct);
JObject statusJson = ParseOk(statusRes, apiKey, "status");
string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant();
var result = new ProviderPollResult();
switch (status)
{
case "COMPLETED":
case "OK":
result.State = ProviderPollState.Succeeded;
break;
case "IN_PROGRESS":
result.State = ProviderPollState.Running;
return result;
case "IN_QUEUE":
result.State = ProviderPollState.Queued;
return result;
case "ERROR":
case "FAILED":
result.State = ProviderPollState.Failed;
result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey);
return result;
default:
result.State = ProviderPollState.Running;
return result;
}
// Completed: fetch the result payload and extract the first image URL.
var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl };
resultSpec.Headers["Authorization"] = "Key " + apiKey;
HttpResult resultRes = await http.SendAsync(resultSpec, ct);
JObject resultJson = ParseOk(resultRes, apiKey, "result");
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.";
}
return result;
}
private static string ExtractImageUrl(JObject result)
{
JToken images = result["images"];
if (images is JArray arr && arr.Count > 0)
{
string u = arr[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
string single = result["image"]?["url"]?.ToString();
return string.IsNullOrEmpty(single) ? null : single;
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a36ad2c4285c4799ae9eb4495813a44c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side
/// job id; poll reports progress and, on success, the download URL. The api key is passed in
/// at call time and never cached on the adapter.
/// </summary>
public interface IModelProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A generative 2D image provider (fal, OpenRouter, ...). Phase 7.</summary>
public interface IImageProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6.</summary>
public interface IMarketplaceProviderAdapter
{
string Id { get; }
Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f76130e504cd4423abbe82bbb718d25b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode
/// it as a base64 <c>data:</c> URI — the inline form fal, Meshy, and OpenRouter accept for image
/// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately.
/// </summary>
internal static class LocalImage
{
// Extensions that can be inlined as a data URI for provider image input.
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".png", ".jpg", ".jpeg", ".webp", ".gif" };
/// <summary>
/// Resolve an image path under the project's Assets folder to an existing absolute file of
/// a supported image type. Returns false with a user-facing <paramref name="error"/> for
/// an unsafe path, a missing file, or an unsupported extension, so the handler can fail
/// fast before any provider request is made.
/// </summary>
public static bool ResolveExisting(string path, out string absPath, out string error)
{
absPath = null;
error = null;
if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; }
if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel))
{
error = "image_path must point to a file under the project's Assets folder.";
return false;
}
string abs = AssetGenPaths.ToAbsolute(rel);
if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; }
if (!SupportedExtensions.Contains(Path.GetExtension(abs)))
{
error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif.";
return false;
}
absPath = abs;
return true;
}
/// <summary>
/// Read a local image and return a "data:image/&lt;mime&gt;;base64,..." URI. Throws
/// <see cref="NotSupportedException"/> for an unsupported extension.
/// </summary>
public static string ToDataUri(string absPath)
{
if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel))
throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder.");
absPath = AssetGenPaths.ToAbsolute(rel);
string mime = MimeFromExtension(Path.GetExtension(absPath));
byte[] bytes = File.ReadAllBytes(absPath);
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
}
private static string MimeFromExtension(string ext)
{
switch ((ext ?? string.Empty).ToLowerInvariant())
{
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".gif": return "image/gif";
default:
throw new NotSupportedException(
$"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f8290c406df4661b9ad2ff6cd324871
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,208 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry
/// only); when textures are requested it then issues a "refine" task and surfaces the textured
/// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each
/// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and
/// never logged; every error is run through <see cref="SecretRedactor"/>.
/// </summary>
public sealed class MeshyAdapter : IModelProviderAdapter
{
private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d";
private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d";
public string Id => "meshy";
// Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow.
private string _format = "glb";
private bool _isImage;
private bool _wantTexture = true;
private string _refineTaskId;
private bool _refineSubmitted;
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
_wantTexture = req.Texture;
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
JObject body;
string url;
if (_isImage)
{
// image→3D textures in a single call (no separate refine task). image_url accepts a
// hosted URL or an inline base64 data URI (for a local image_path).
url = ImageEndpoint;
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body = new JObject
{
["image_url"] = imageRef,
["ai_model"] = "meshy-6",
["should_texture"] = _wantTexture
};
}
else
{
// text→3D preview is geometry only; texturing happens via a follow-up refine task.
url = TextEndpoint;
body = new JObject
{
["mode"] = "preview",
["prompt"] = req.Prompt ?? string.Empty,
["ai_model"] = "meshy-6"
};
}
return await PostTask(url, body, apiKey, http, ct, "submit");
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
bool refinePhase = _refineSubmitted;
string pollId = refinePhase ? _refineTaskId : providerJobId;
// image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d.
string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint;
var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId };
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "poll");
ProviderPollState state = MapState(json["status"]?.ToString());
var result = new ProviderPollResult { State = state };
// Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1).
bool twoPhase = !_isImage && _wantTexture;
float raw = 0f;
JToken prog = json["progress"];
if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value<float>() / 100f);
result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f);
if (state == ProviderPollState.Succeeded)
{
// Preview just finished and textures were requested: start the refine task and keep
// polling it; never surface the untextured preview result.
if (twoPhase && !refinePhase)
{
var refineBody = new JObject
{
["mode"] = "refine",
["preview_task_id"] = providerJobId,
["ai_model"] = "meshy-6"
};
_refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine");
_refineSubmitted = true;
result.State = ProviderPollState.Running;
result.Progress = 0.5f;
return result;
}
result.Progress = 1f;
result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Meshy reported success but no model URL was present in the response.";
}
}
else if (state == ProviderPollState.Failed)
{
string err = json["task_error"]?["message"]?.ToString()
?? json["message"]?.ToString()
?? "Meshy task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
/// <summary>POST a task body and return its <c>result</c> task id (or null).</summary>
private static async Task<string> PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase)
{
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, phase);
string id = json["result"]?.ToString();
if (string.IsNullOrEmpty(id))
throw new Exception(SecretRedactor.Scrub(
$"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey));
return id;
}
private string ExtractModelUrl(JObject urls)
{
if (urls == null) return null;
string byFormat = urls[_format]?.ToString();
if (!string.IsNullOrEmpty(byFormat)) return byFormat;
string glb = urls["glb"]?.ToString();
if (!string.IsNullOrEmpty(glb)) return glb;
string fbx = urls["fbx"]?.ToString();
return string.IsNullOrEmpty(fbx) ? null : fbx;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToUpperInvariant())
{
case "SUCCEEDED":
return ProviderPollState.Succeeded;
case "FAILED":
case "EXPIRED":
case "CANCELED":
case "CANCELLED":
return ProviderPollState.Failed;
case "IN_PROGRESS":
return ProviderPollState.Running;
case "PENDING":
default:
return ProviderPollState.Queued;
}
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb6ba98874ed46cca5f019c89b2edb0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// 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
/// work happens in <see cref="SubmitAsync"/> and <see cref="PollAsync"/> returns it immediately.
/// One adapter instance handles a single job (the job manager captures it for submit+poll).
/// </summary>
public sealed class OpenRouterAdapter : IImageProviderAdapter
{
private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
private const string DefaultModel = "google/gemini-2.5-flash-image";
public string Id => "openrouter";
private byte[] _inlineData;
private string _downloadUrl;
private string _error;
public async Task<string> 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));
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
// image->image: attach the reference image as an image_url content part alongside the
// text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a
// base64 data URI. Plain text->image uses a string content.
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
// image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path).
string imageRef = image
? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath))
: null;
JToken content = image
? new JArray(
new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty },
new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } })
: (JToken)(req.Prompt ?? string.Empty);
var body = new JObject
{
["model"] = model,
["modalities"] = new JArray("image", "text"),
["messages"] = new JArray(new JObject
{
["role"] = "user",
["content"] = content
})
};
var spec = new HttpRequestSpec
{
Method = "POST",
Url = Endpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey);
string url = ExtractImageUrl(json);
if (string.IsNullOrEmpty(url))
{
_error = "OpenRouter returned no image. The selected model may not support image output.";
return "ready";
}
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
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."; }
}
else
{
_downloadUrl = url;
}
return "ready";
}
public Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
var result = new ProviderPollResult { Progress = 1f };
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
{
result.State = ProviderPollState.Failed;
result.Error = _error ?? "OpenRouter produced no image.";
}
else
{
result.State = ProviderPollState.Succeeded;
result.InlineData = _inlineData;
result.DownloadUrl = _downloadUrl;
}
return Task.FromResult(result);
}
private static string ExtractImageUrl(JObject json)
{
JToken message = json["choices"]?[0]?["message"];
if (message == null) return null;
// Preferred: message.images[].image_url.url
if (message["images"] is JArray imgs && imgs.Count > 0)
{
string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
// Fallback: a content array with image_url parts
if (message["content"] is JArray parts)
{
foreach (JToken part in parts)
{
string u = part?["image_url"]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
}
return null;
}
private static JObject ParseOk(HttpResult res, string apiKey)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 55b1b120b06948f6aeada0381b51ce86
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Shared HTTP-response helpers for provider adapters: read the response text (falling back to
/// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages.
/// </summary>
internal static class ProviderHttp
{
/// <summary>Response text, falling back to a UTF-8 decode of the raw body when Text is empty.</summary>
public static string BodyText(HttpResult res)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null)
text = Encoding.UTF8.GetString(res.Body);
return text;
}
/// <summary>Cap a (possibly null) string at 500 chars for inclusion in an error message.</summary>
public static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1584e5cffb5b45e6816fec509f1bfc8b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>Normalized lifecycle state reported by a provider poll, across all providers.</summary>
public enum ProviderPollState
{
Queued,
Running,
Succeeded,
Failed
}
/// <summary>
/// Outcome of a single provider poll. <see cref="Progress"/> is normalized to 0..1.
/// On <see cref="ProviderPollState.Succeeded"/>, <see cref="DownloadUrl"/> points at the
/// result the C# side will download into the project. On
/// <see cref="ProviderPollState.Failed"/>, <see cref="Error"/> carries a redacted message.
/// </summary>
public sealed class ProviderPollResult
{
public ProviderPollState State;
public float Progress;
public string DownloadUrl;
/// <summary>Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter),
/// so the job manager skips the download step. Takes precedence over <see cref="DownloadUrl"/>.</summary>
public byte[] InlineData;
/// <summary>Overrides the downloaded file extension, e.g. "zip" for archive results.</summary>
public string ResultExt;
public string Error;
}
/// <summary>Request to generate a 3D model. Shared by every model provider adapter.</summary>
public sealed class ModelGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Format = "glb";
public float TargetSize = 1f;
public bool Texture = true;
public string Tier;
public string Name;
public string OutputFolder;
}
/// <summary>Request to generate a 2D image. Shared by every image provider adapter.</summary>
public sealed class ImageGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Model;
public bool Transparent;
public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture
public int Width;
public int Height;
public string Name;
public string OutputFolder;
}
/// <summary>
/// Public, key-free description of a provider for <c>list_providers</c>. Never carries a key
/// value — <see cref="Configured"/> reports existence only.
/// </summary>
public sealed class ProviderInfo
{
public string Id;
public string Kind; // model | image | marketplace
public bool Configured;
public string[] Capabilities;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65be6fd6b6384effb45822a57e541f04
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,96 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API
/// JSON to the caller; <see cref="ResolveDownloadUrlAsync"/> hits the model download endpoint
/// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the
/// "Token &lt;key&gt;" scheme; the key is supplied per call and never logged.
/// </summary>
public sealed class SketchfabAdapter : IMarketplaceProviderAdapter
{
private const string SearchEndpoint = "https://api.sketchfab.com/v3/search";
private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models";
public string Id => "sketchfab";
public async Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (http == null) throw new ArgumentNullException(nameof(http));
string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false")
+ "&q=" + Uri.EscapeDataString(query ?? string.Empty);
if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories);
if (count.HasValue) url += "&count=" + count.Value;
if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor);
var spec = new HttpRequestSpec { Method = "GET", Url = url };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
// The raw response carries pagination (`cursors.next` / `next`) for the caller to page.
return RawOk(res, apiKey, "search");
}
public async Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
return RawOk(res, apiKey, "preview");
}
public async Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "download");
string url = json["gltf"]?["url"]?.ToString();
if (string.IsNullOrEmpty(url))
{
throw new Exception(SecretRedactor.Scrub(
$"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey));
}
return url;
}
private static string RawOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
bool ok = res?.Ok == true;
if (!ok)
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey));
return text ?? string.Empty;
}
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
}
bool ok = res?.Ok == true;
if (!ok)
{
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 860350c25d8d490aaaa925d76ce29407
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,222 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and
/// polls for completion. The bearer key is supplied per call and never logged; every error
/// message is run through <see cref="SecretRedactor"/> before it is surfaced.
/// </summary>
public sealed class TripoAdapter : IModelProviderAdapter
{
private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task";
// Current recommended Tripo model (v3.1). Premium alternative: P1-20260311.
private const string ModelVersion = "v3.1-20260211";
public string Id => "tripo";
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
// Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files,
// which isn't wired yet — fail clearly rather than silently falling back to text mode.
bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase);
if (imageMode && string.IsNullOrEmpty(req.ImageUrl))
throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image).");
JObject body;
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
if (image)
{
body = new JObject
{
["type"] = "image_to_model",
["file"] = new JObject
{
["type"] = "url",
["url"] = req.ImageUrl
}
};
}
else
{
body = new JObject
{
["type"] = "text_to_model",
["prompt"] = req.Prompt ?? string.Empty,
["model_version"] = ModelVersion
};
}
var spec = new HttpRequestSpec
{
Method = "POST",
Url = TaskEndpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "submit");
string taskId = json["data"]?["task_id"]?.ToString();
if (string.IsNullOrEmpty(taskId))
{
throw new Exception(SecretRedactor.Scrub(
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
}
return taskId;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec
{
Method = "GET",
Url = TaskEndpoint + "/" + providerJobId
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "poll");
JObject data = json["data"] as JObject ?? new JObject();
var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) };
JToken progressTok = data["progress"];
if (progressTok != null && progressTok.Type != JTokenType.Null)
{
result.Progress = Mathf.Clamp01(progressTok.Value<float>() / 100f);
}
if (result.State == ProviderPollState.Succeeded)
{
result.Progress = 1f;
result.DownloadUrl = ExtractDownloadUrl(data);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Tripo reported success but no model URL was present in the response.";
}
}
else if (result.State == ProviderPollState.Failed)
{
string err = data["error"]?.ToString()
?? data["message"]?.ToString()
?? json["message"]?.ToString()
?? "Tripo task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToLowerInvariant())
{
case "success":
case "succeeded":
return ProviderPollState.Succeeded;
case "failed":
case "error":
case "cancelled":
case "canceled":
case "banned":
case "expired":
return ProviderPollState.Failed;
case "running":
case "processing":
return ProviderPollState.Running;
default:
return ProviderPollState.Queued;
}
}
/// <summary>
/// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a
/// textured / PBR model; fall back to the base model. Accepts both the newer flat form
/// (<c>output.pbr_model</c> = url string) and the older nested form
/// (<c>result.pbr_model.url</c> = object with a url field).
/// </summary>
private static string ExtractDownloadUrl(JObject data)
{
JObject output = data["output"] as JObject;
JObject resultObj = data["result"] as JObject;
return UrlOf(output?["pbr_model"])
?? UrlOf(resultObj?["pbr_model"])
?? UrlOf(output?["model"])
?? UrlOf(resultObj?["model"])
?? UrlOf(output?["base_model"])
?? UrlOf(resultObj?["base_model"]);
}
/// <summary>A field may be a plain URL string or an object carrying a "url" property.</summary>
private static string UrlOf(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token.Type == JTokenType.String)
{
string s = token.ToString();
return string.IsNullOrEmpty(s) ? null : s;
}
if (token is JObject obj)
{
string u = obj["url"]?.ToString();
return string.IsNullOrEmpty(u) ? null : u;
}
return null;
}
/// <summary>
/// Validate transport success + Tripo's body-level <c>code</c> (0 == ok), parse the JSON,
/// and throw a redacted exception otherwise.
/// </summary>
private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); }
catch { /* non-JSON body; handled below */ }
}
bool httpOk = res?.Ok == true;
int code = 0;
JToken codeTok = json?["code"];
if (codeTok != null && codeTok.Type != JTokenType.Null)
{
try { code = codeTok.Value<int>(); } catch { code = -1; }
}
if (!httpOk || code != 0)
{
string detail = json?["message"]?.ToString()
?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a0761a0b205e4041950b0ab29a3d6466
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: