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 { /// /// 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 before it is surfaced. /// 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 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 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() / 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; } } /// /// 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 /// (output.pbr_model = url string) and the older nested form /// (result.pbr_model.url = object with a url field). /// 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"]); } /// A field may be a plain URL string or an object carrying a "url" property. 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; } /// /// Validate transport success + Tripo's body-level code (0 == ok), parse the JSON, /// and throw a redacted exception otherwise. /// 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(); } 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(); } } }