package app // 声明式协议插件宿主与接口类型校验。 import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net" "net/http" "net/textproto" "net/url" "sort" "strconv" "strings" "time" "infinite-canvas/backend/internal/protocol" "github.com/google/uuid" "github.com/volcengine/volc-sdk-golang/base" ) // runDeclarativeProtocolTask 是 JSON manifest 插件的宿主运行时。 // manifest 只能描述请求/响应映射;凭证、出站安全策略、轮询和结果下载必须由宿主统一掌握, // 这样插件不能绕过服务端的鉴权、超时和 SSRF 防护边界。 func runDeclarativeProtocolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { adapter, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType) if !ok { return nil, fmt.Errorf("接口类型 %s 未安装声明式适配器", input.Config.InterfaceType) } return runProtocolAdapterTask(ctx, input, adapter) } func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter) (map[string]interface{}, error) { return runProtocolAdapterTaskWithPolicy(ctx, input, adapter, declarativeProtocolPollPolicy(input.Mode)) } func declarativeProtocolPollPolicy(mode string) videoPollPolicy { if mode == "video" { return defaultVideoPollPolicy() } return videoPollPolicy{ Interval: 2500 * time.Millisecond, MaxNotFoundMisses: 1, MaxDownloadTries: 3, Sleep: sleepContext, } } func runProtocolAdapterTaskWithPolicy(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, policy videoPollPolicy) (map[string]interface{}, error) { // create、poll、download 是同一个外部任务的三个阶段:任一阶段失败都向上返回真实错误, // 不把“已提交但结果未知”伪装成成功,也不把下载失败降级成空结果。 request := protocolRequestFromInput(input) taskID := resumedProviderRequestID(ctx) var created protocol.CreateResult if taskID == "" { // 幂等键只存在于宿主请求元数据中,声明式插件可以把它映射到 Header, // 但不能把宿主控制字段泄漏到供应商 JSON body。恢复已有 taskID 时不会进入 create 分支。 key, _ := ctx.Value(providerSubmissionKeyContext{}).(string) if key == "" { key = uuid.NewString() } request.Extra["idempotencyKey"] = key spec, err := adapter.BuildCreate(ctx, protocol.RequestContext{BaseURL: input.Config.BaseURL, Request: request}) if err != nil { return nil, err } body, err := executeProtocolRequest(withProviderRequestKind(ctx, "create"), input.Config, spec) if err != nil { return nil, err } created, err = adapter.ParseCreate(ctx, body) if err != nil { return nil, err } taskID = created.TaskID if taskID == "" { extracted, extractErr := extractProviderTaskID(body) if extractErr != nil { return nil, extractErr } taskID = extracted } if created.Status == protocol.StatusFailed || created.Status == protocol.StatusCancelled { return nil, protocolResultError(created.Message, taskID) } if created.Status == protocol.StatusSucceeded { return finishProtocolAdapterResult(ctx, input, adapter, request, taskID, created.Result, policy) } if taskID == "" { return nil, errors.New("声明式协议创建请求没有返回任务 ID") } } return runVideoPollLoop(ctx, taskID, policy, func(ctx context.Context) (videoPollOutcome, error) { spec, err := adapter.BuildPoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) if err != nil { return videoPollOutcome{}, err } body, err := executeProtocolRequest(withProviderRequestKind(ctx, "poll"), input.Config, spec) if err != nil { return videoPollOutcome{}, err } state, err := adapter.ParsePoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}, body) if err != nil { return videoPollOutcome{}, err } if state.TaskID != "" { taskID = state.TaskID } switch state.Status { case protocol.StatusSucceeded: result, err := finishProtocolAdapterResult(ctx, input, adapter, request, taskID, state.Result, policy) return videoPollOutcome{Done: err == nil, Result: result}, err case protocol.StatusFailed, protocol.StatusCancelled: return videoPollOutcome{}, protocolResultError(state.Message, taskID) } return videoPollOutcome{}, nil }) } func extractProviderTaskID(body []byte) (string, error) { var payload map[string]interface{} if json.Unmarshal(body, &payload) != nil { return "", nil } id, err := firstJSONString(payload, "id", "task_id", "taskId", "request_id", "name") if id != "" { return id, nil } if data, ok := payload["data"].(map[string]interface{}); ok { nested, nestedErr := firstJSONString(data, "id", "task_id", "taskId", "request_id") if nested != "" { return nested, nil } if nestedErr != nil { return "", nestedErr } } return "", err } // queryProtocolAdapterVideoTask 只读取一次已有的声明式 Provider 任务。 // 人工恢复走此路径,因此检查本地失败任务时绝不会创建第二个计费任务。 func queryProtocolAdapterVideoTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, taskID string) (map[string]interface{}, string, error) { request := protocolRequestFromInput(input) pollContext := protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID} spec, err := adapter.BuildPoll(ctx, pollContext) if err != nil { return nil, "", err } body, err := executeProtocolRequest(withProviderRequestKind(ctx, "poll"), input.Config, spec) if err != nil { return nil, "", err } state, err := adapter.ParsePoll(ctx, pollContext, body) if err != nil { return nil, "", err } providerStatus := string(state.Status) switch state.Status { case protocol.StatusSucceeded: result, err := finishProtocolAdapterResult(ctx, input, adapter, request, taskID, state.Result, defaultVideoPollPolicy()) return result, providerStatus, err case protocol.StatusFailed, protocol.StatusCancelled: return nil, providerStatus, protocolResultError(state.Message, taskID) case protocol.StatusPending, protocol.StatusProcessing: return nil, providerStatus, nil default: return nil, providerStatus, fmt.Errorf("声明式协议任务 %s 返回未知状态:%s", taskID, providerStatus) } } func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRequest { resolution := strings.TrimSpace(input.Config.VQuality) if input.Mode == "video" { if declared := videoResolutionNameRequest(input.VideoCapability, resolution); declared != "" { resolution = declared } } request := protocol.GenerationRequest{ Capability: protocol.Capability(input.Mode), Model: input.Config.Model, Prompt: input.Prompt, Instructions: strings.TrimSpace(input.Config.SystemPrompt), Images: protocolImageReferences(input), Videos: protocolMediaReferences(input.ReferenceVideos, "video"), Audios: protocolMediaReferences(input.ReferenceAudios, "audio"), AspectRatio: input.Config.Size, Resolution: resolution, Quality: input.Config.Quality, GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false), Watermark: parseBool(input.Config.VideoWatermark, false), Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")), Extra: map[string]any{ "videoSeconds": input.Config.VideoSeconds, "audioVoice": input.Config.AudioVoice, "audioFormat": input.Config.AudioFormat, "count": input.Config.Count, }, } for _, message := range input.TextHistory { role := strings.ToLower(strings.TrimSpace(message.Role)) if role != "user" && role != "assistant" && role != "system" { continue } if content := strings.TrimSpace(message.Content); content != "" { request.Messages = append(request.Messages, protocol.Message{Role: role, Content: content}) } } request.Inputs = append(request.Inputs, request.Images...) request.Inputs = append(request.Inputs, request.Videos...) request.Inputs = append(request.Inputs, request.Audios...) if input.MaxOutputTokens > 0 { request.Extra["max_output_tokens"] = input.MaxOutputTokens request.Extra["max_tokens"] = input.MaxOutputTokens } if duration, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)); err == nil && duration > 0 { request.Duration = duration } if count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)); err == nil && count > 0 { request.ImageCount = count } request.Output = protocol.OutputOptions{ Count: request.ImageCount, Duration: request.Duration, AspectRatio: request.AspectRatio, Resolution: request.Resolution, Quality: request.Quality, GenerateAudio: request.GenerateAudio, Watermark: request.Watermark, Format: input.Config.AudioFormat, } request.ProviderOptions = make(map[string]map[string]any) if configured, ok := input.Metadata["providerOptions"].(map[string]any); ok { for namespace, raw := range configured { if options, ok := raw.(map[string]any); ok { request.ProviderOptions[strings.TrimSpace(namespace)] = options } } } return request } func protocolImageReferences(input canvasGenerationInput) []protocol.MediaReference { if input.Mode == "video" { return protocolVideoImageReferences(input) } result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)+1) for index, value := range input.ReferenceImages { item := protocolMediaReference(value, "image", index) item.Role = "reference_image" if input.Mode == "image" { item.Role = "edit_source" } if item.URL != "" || item.DataURL != "" { result = append(result, item) } } if input.Mask != nil { mask := protocolMediaReference(*input.Mask, "image", len(result)) mask.Role = "mask" if mask.URL != "" || mask.DataURL != "" { result = append(result, mask) } } return result } func protocolVideoImageReferences(input canvasGenerationInput) []protocol.MediaReference { result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)) fallbackRole := "" if metadataString(input.Metadata, "videoStartFrameNodeId") != "" || metadataString(input.Metadata, "videoEndFrameNodeId") != "" { fallbackRole = "reference_image" } for index, value := range input.ReferenceImages { item := protocolMediaReference(value, "image", index) item.Role = videoImageRoleOrDefault(input, value, fallbackRole) if item.URL != "" || item.DataURL != "" { result = append(result, item) } } return result } func protocolMediaReferences(values []providerMedia, kind string) []protocol.MediaReference { result := make([]protocol.MediaReference, 0, len(values)) for index, value := range values { item := protocolMediaReference(value, kind, index) if kind == "video" { item.Role = "reference_video" } else if kind == "audio" { item.Role = "reference_audio" } if item.URL != "" || item.DataURL != "" { result = append(result, item) } } return result } func protocolMediaReference(value providerMedia, kind string, order int) protocol.MediaReference { return protocol.MediaReference{ ID: strings.TrimSpace(value.ID), URL: strings.TrimSpace(value.URL), DataURL: strings.TrimSpace(value.DataURL), Kind: kind, MIMEType: firstNonEmpty(strings.TrimSpace(value.MimeType), strings.TrimSpace(value.Type)), Name: strings.TrimSpace(value.Name), Order: order, Metadata: map[string]any{"bytes": value.Bytes, "width": value.Width, "height": value.Height, "durationMs": value.DurationMs, "storageKey": strings.TrimSpace(value.StorageKey)}, } } func executeProtocolRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, error) { data, _, err := executeProtocolBinaryRequest(ctx, config, spec) return data, err } // executeProtocolBinaryRequest 是声明式插件与宿主网络能力之间的边界。manifest 只能声明 // method/path/body/auth;最终 URL 校验、凭证注入、SSRF、超时、大小限制和审计仍由宿主统一执行, // 插件不能通过自定义请求规格绕过这些安全约束。 func executeProtocolBinaryRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, string, error) { if err := spec.Validate(); err != nil { return nil, "", err } method := strings.ToUpper(strings.TrimSpace(spec.Method)) body, contentType, err := protocolRequestBody(ctx, config, spec) if err != nil { return nil, "", err } requestURL, err := protocolRequestURL(config.BaseURL, spec) if err != nil { return nil, "", err } req, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { return nil, "", err } if contentType != "" { req.Header.Set("Content-Type", contentType) } for name, value := range spec.Headers { req.Header.Set(name, value) } ApplyOutboundHeaders(req, config.Headers) if err := applyProtocolAuth(req, config, spec.Auth); err != nil { return nil, "", err } return doBinary(req) } func protocolRequestBody(ctx context.Context, config providerConfig, spec protocol.RequestSpec) (io.Reader, string, error) { contentType := strings.ToLower(strings.TrimSpace(strings.Split(spec.ContentType, ";")[0])) if spec.Body == nil && len(spec.Files) == 0 { return nil, "", nil } switch contentType { case "", "application/json": data, err := json.Marshal(spec.Body) if err != nil { return nil, "", err } return bytes.NewReader(data), "application/json", nil case "application/x-www-form-urlencoded": values := url.Values{} for key, value := range protocolBodyObject(spec.Body) { for _, item := range protocolFormValues(value) { values.Add(key, item) } } return strings.NewReader(values.Encode()), contentType, nil case "multipart/form-data": var body bytes.Buffer writer := multipart.NewWriter(&body) keys := make([]string, 0) for key := range protocolBodyObject(spec.Body) { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { for _, item := range protocolFormValues(protocolBodyObject(spec.Body)[key]) { if err := writer.WriteField(key, item); err != nil { _ = writer.Close() return nil, "", err } } } for _, file := range spec.Files { data, detectedMIME, err := protocolMediaBytes(ctx, config, file.Reference) if err != nil { _ = writer.Close() return nil, "", fmt.Errorf("读取 multipart 文件 %s 失败:%w", file.Name, err) } filename := safeProtocolFilename(file.Filename) mimeType := strings.TrimSpace(file.MIMEType) if mimeType == "" { mimeType = detectedMIME } header := make(textproto.MIMEHeader) header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, file.Name, filename)) header.Set("Content-Type", defaultString(mimeType, "application/octet-stream")) part, err := writer.CreatePart(header) if err != nil { _ = writer.Close() return nil, "", err } if _, err := part.Write(data); err != nil { _ = writer.Close() return nil, "", err } } if err := writer.Close(); err != nil { return nil, "", err } return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil case "application/octet-stream": switch value := spec.Body.(type) { case []byte: return bytes.NewReader(value), contentType, nil case string: if strings.HasPrefix(value, "data:") { mimeType, data, err := decodeProviderDataURL(value) if err != nil { return nil, "", err } return bytes.NewReader(data), defaultString(mimeType, contentType), nil } return strings.NewReader(value), contentType, nil default: return nil, "", fmt.Errorf("二进制协议请求体必须是字节或字符串") } default: return nil, "", fmt.Errorf("声明式协议暂不支持 %s 请求体", spec.ContentType) } } func protocolBodyObject(value any) map[string]any { result, _ := value.(map[string]any) return result } func protocolFormValues(value any) []string { switch typed := value.(type) { case nil: return nil case []any: result := make([]string, 0, len(typed)) for _, item := range typed { result = append(result, protocolFormValues(item)...) } return result case string: return []string{typed} case bool: return []string{strconv.FormatBool(typed)} case float64: return []string{strconv.FormatFloat(typed, 'f', -1, 64)} case int: return []string{strconv.Itoa(typed)} default: data, err := json.Marshal(typed) if err != nil { return nil } return []string{string(data)} } } func safeProtocolFilename(value string) string { value = strings.TrimSpace(value) if index := strings.LastIndexAny(value, `/\\`); index >= 0 { value = value[index+1:] } value = strings.Map(func(r rune) rune { if r < 32 || r == 127 || r == '"' { return -1 } return r }, value) if value == "" { return "upload.bin" } return value } func applyProtocolAuth(req *http.Request, config providerConfig, auth protocol.ManifestAuth) error { typeName := strings.ToLower(strings.TrimSpace(auth.Type)) if typeName == "" { applyProviderAuth(req, config) return nil } credential := protocolCredentialField(config, auth.Field) switch typeName { case "none": return nil case "bearer": header := defaultString(strings.TrimSpace(auth.Header), "Authorization") prefix := auth.Prefix if prefix == "" { prefix = "Bearer " } req.Header.Set(header, prefix+credential) return nil case "header", "api-key", "apikey": header := strings.TrimSpace(auth.Header) if header == "" { return errors.New("插件 header 鉴权缺少 header 名称") } req.Header.Set(header, auth.Prefix+credential) return nil case "query": name := defaultString(strings.TrimSpace(auth.Query), strings.TrimSpace(auth.Field)) if name == "" { return errors.New("插件 query 鉴权缺少参数名") } query := req.URL.Query() query.Set(name, auth.Prefix+credential) req.URL.RawQuery = query.Encode() return nil case "basic": username := auth.Username if username == "" { username = credential } password := protocolCredentialField(config, auth.SecretField) req.SetBasicAuth(username, password) return nil case "anthropic": req.Header.Set(defaultString(auth.Header, "x-api-key"), credential) if req.Header.Get("anthropic-version") == "" { req.Header.Set("anthropic-version", "2023-06-01") } return nil case "google-api-key", "gemini": req.Header.Set(defaultString(auth.Header, "x-goog-api-key"), credential) return nil case "volcengine-v4": secret := protocolCredentialField(config, auth.SecretField) if credential == "" || secret == "" { return errors.New("火山引擎 V4 鉴权需要 Access Key 和 Secret Key") } credentials := base.Credentials{ AccessKeyID: credential, SecretAccessKey: secret, Region: defaultString(strings.TrimSpace(auth.Region), "cn-north-1"), Service: strings.TrimSpace(auth.Service), } if credentials.Service == "" { return errors.New("火山引擎 V4 鉴权缺少 service") } signed := credentials.Sign(req) *req = *signed return nil case "aws-sigv4": secret := protocolCredentialField(config, auth.SecretField) return signProtocolAWSV4(req, credential, secret, auth) case "tc3": secret := protocolCredentialField(config, auth.SecretField) return signProtocolTC3(req, credential, secret, auth) default: return fmt.Errorf("插件声明了尚未启用的鉴权驱动 %s", auth.Type) } } func signProtocolAWSV4(req *http.Request, accessKey, secretKey string, auth protocol.ManifestAuth) error { if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" { return errors.New("AWS SigV4 鉴权需要 Access Key ID 和 Secret Access Key") } serviceName := defaultString(strings.TrimSpace(auth.Service), "bedrock") region := strings.TrimSpace(auth.Region) if region == "" { parts := strings.Split(strings.ToLower(req.URL.Hostname()), ".") for index, part := range parts { if strings.HasPrefix(part, serviceName) && index+1 < len(parts) { region = parts[index+1] break } } } if region == "" { return errors.New("AWS SigV4 鉴权无法从 Base URL 推断 region,请使用包含区域的 Bedrock Runtime 地址") } payload, err := protocolRequestPayload(req) if err != nil { return err } now := time.Now().UTC() amzDate := now.Format("20060102T150405Z") dateStamp := now.Format("20060102") payloadHash := sha256Hex(payload) req.Header.Set("X-Amz-Date", amzDate) req.Header.Set("X-Amz-Content-Sha256", payloadHash) canonicalHeaders, signedHeaders := protocolCanonicalHeaders(req) canonicalRequest := strings.Join([]string{ req.Method, defaultString(req.URL.EscapedPath(), "/"), req.URL.Query().Encode(), canonicalHeaders, signedHeaders, payloadHash, }, "\n") scope := strings.Join([]string{dateStamp, region, serviceName, "aws4_request"}, "/") stringToSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, scope, sha256Hex([]byte(canonicalRequest))}, "\n") dateKey := protocolHMAC([]byte("AWS4"+secretKey), dateStamp) regionKey := protocolHMAC(dateKey, region) serviceKey := protocolHMAC(regionKey, serviceName) signingKey := protocolHMAC(serviceKey, "aws4_request") signature := hex.EncodeToString(protocolHMAC(signingKey, stringToSign)) req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", accessKey, scope, signedHeaders, signature)) return nil } func signProtocolTC3(req *http.Request, secretID, secretKey string, auth protocol.ManifestAuth) error { if strings.TrimSpace(secretID) == "" || strings.TrimSpace(secretKey) == "" { return errors.New("腾讯云 TC3 鉴权需要 SecretId 和 SecretKey") } serviceName := defaultString(strings.TrimSpace(auth.Service), "hunyuan") payload, err := protocolRequestPayload(req) if err != nil { return err } now := time.Now().UTC() timestamp := now.Unix() dateStamp := now.Format("2006-01-02") contentType := defaultString(req.Header.Get("Content-Type"), "application/json") req.Header.Set("Content-Type", contentType) req.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) if region := strings.TrimSpace(auth.Region); region != "" { req.Header.Set("X-TC-Region", region) } canonicalHeaders := "content-type:" + strings.ToLower(strings.TrimSpace(contentType)) + "\n" + "host:" + strings.ToLower(req.URL.Host) + "\n" signedHeaders := "content-type;host" canonicalRequest := strings.Join([]string{req.Method, defaultString(req.URL.EscapedPath(), "/"), req.URL.Query().Encode(), canonicalHeaders, signedHeaders, sha256Hex(payload)}, "\n") scope := dateStamp + "/" + serviceName + "/tc3_request" stringToSign := strings.Join([]string{"TC3-HMAC-SHA256", strconv.FormatInt(timestamp, 10), scope, sha256Hex([]byte(canonicalRequest))}, "\n") secretDate := protocolHMAC([]byte("TC3"+secretKey), dateStamp) secretService := protocolHMAC(secretDate, serviceName) secretSigning := protocolHMAC(secretService, "tc3_request") signature := hex.EncodeToString(protocolHMAC(secretSigning, stringToSign)) req.Header.Set("Authorization", fmt.Sprintf("TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", secretID, scope, signedHeaders, signature)) return nil } func protocolRequestPayload(req *http.Request) ([]byte, error) { if req.Body == nil { return nil, nil } var reader io.ReadCloser var err error if req.GetBody != nil { reader, err = req.GetBody() } else { reader = req.Body } if err != nil { return nil, err } data, err := io.ReadAll(reader) if req.GetBody != nil { _ = reader.Close() } else { req.Body = io.NopCloser(bytes.NewReader(data)) } return data, err } func protocolCanonicalHeaders(req *http.Request) (string, string) { values := map[string]string{"host": strings.ToLower(req.URL.Host)} for name, entries := range req.Header { lower := strings.ToLower(strings.TrimSpace(name)) if lower == "authorization" || lower == "user-agent" || lower == "content-length" || lower == "expect" { continue } cleaned := make([]string, 0, len(entries)) for _, entry := range entries { cleaned = append(cleaned, strings.Join(strings.Fields(entry), " ")) } values[lower] = strings.Join(cleaned, ",") } keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) var canonical strings.Builder for _, key := range keys { canonical.WriteString(key) canonical.WriteByte(':') canonical.WriteString(values[key]) canonical.WriteByte('\n') } return canonical.String(), strings.Join(keys, ";") } func protocolHMAC(key []byte, value string) []byte { mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(value)) return mac.Sum(nil) } func sha256Hex(value []byte) string { hash := sha256.Sum256(value) return hex.EncodeToString(hash[:]) } func protocolCredentialField(config providerConfig, field string) string { switch strings.ToLower(strings.TrimSpace(field)) { case "secretkey", "secret_key", "secret": return strings.TrimSpace(config.SecretKey) default: return strings.TrimSpace(config.APIKey) } } func protocolRequestURL(baseURL string, spec protocol.RequestSpec) (string, error) { if !spec.OriginPath { return appendProtocolQuery(apiURL(baseURL, spec.Path), spec.Query) } base, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || base.Scheme == "" || base.Host == "" { return "", fmt.Errorf("协议根路径请求的 Base URL 无效") } requestPath, err := url.Parse(spec.Path) if err != nil || !strings.HasPrefix(requestPath.Path, "/") { return "", fmt.Errorf("协议根路径请求必须使用绝对路径") } base.Path = requestPath.Path base.RawPath = requestPath.RawPath base.RawQuery = requestPath.RawQuery base.Fragment = "" return appendProtocolQuery(base.String(), spec.Query) } func appendProtocolQuery(rawURL string, values map[string][]string) (string, error) { if len(values) == 0 { return rawURL, nil } parsed, err := url.Parse(rawURL) if err != nil { return "", err } query := parsed.Query() for key, items := range values { for _, item := range items { query.Add(key, item) } } parsed.RawQuery = query.Encode() return parsed.String(), nil } func finishProtocolResult(ctx context.Context, config providerConfig, mode string, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { if result == nil { return nil, errors.New("声明式协议已完成但没有返回结果") } if mode == "text" { output := map[string]interface{}{"mode": "text", "text": result.Text} if strings.TrimSpace(result.Reasoning) != "" { output["reasoning"] = result.Reasoning } return output, nil } var references []protocol.MediaReference switch mode { case "image": references = result.Images case "video": references = result.Videos case "audio": references = result.Audios default: return nil, fmt.Errorf("声明式协议不支持生成模式 %s", mode) } if len(references) == 0 { return nil, errors.New("声明式协议已完成但没有返回媒体地址") } items := make([]interface{}, 0, len(references)) for _, reference := range references { var data []byte var mimeType string var err error if mode == "video" { data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, func(ctx context.Context) ([]byte, string, error) { return protocolMediaBytesOnce(ctx, config, reference) }) } else { data, mimeType, err = protocolMediaBytes(ctx, config, reference) } if err != nil { return nil, err } item := map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType} items = append(items, item) } switch mode { case "image": return map[string]interface{}{"mode": "image", "images": items}, nil case "video": return map[string]interface{}{"mode": "video", "video": items[0]}, nil default: return map[string]interface{}{"mode": "audio", "audio": items[0]}, nil } } // finishProtocolAdapterResult 优先消费 create/poll 已返回的内联或 URL 结果,只有插件明确声明独立结果端点时才下载。 // 空响应或下载失败必须向上失败,不能生成伪素材;application/octet-stream 仅表示传输层未知类型, // 不会把未知内容伪装成具体图片、视频或音频 MIME。 func finishProtocolAdapterResult(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, request protocol.GenerationRequest, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { if protocolResultHasOutput(input.Mode, result) { return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) } resultAdapter, ok := adapter.(protocol.ResultAdapter) capability, hasCapability := adapter.(protocol.ResultCapability) if !ok || !hasCapability || !capability.ResultAvailable() { return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) } spec, err := resultAdapter.BuildResult(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) if err != nil { return nil, err } download := func(ctx context.Context) ([]byte, string, error) { return executeProtocolBinaryRequest(withProviderRequestKind(ctx, "download"), input.Config, spec) } var data []byte var mimeType string if input.Mode == "video" { data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, download) } else { data, mimeType, err = download(ctx) } if err != nil { return nil, fmt.Errorf("声明式协议结果下载失败:%w", err) } if len(data) == 0 { return nil, errors.New("声明式协议结果下载返回空内容") } mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) if mimeType == "" { mimeType = "application/octet-stream" } reference := protocol.MediaReference{DataURL: dataURL(mimeType, data), MIMEType: mimeType} downloaded := &protocol.Result{} switch input.Mode { case "image": downloaded.Images = []protocol.MediaReference{reference} case "video": downloaded.Videos = []protocol.MediaReference{reference} case "audio": downloaded.Audios = []protocol.MediaReference{reference} default: return nil, fmt.Errorf("声明式协议结果下载不支持生成模式 %s", input.Mode) } return finishProtocolResult(ctx, input.Config, input.Mode, taskID, downloaded, pollPolicy) } func protocolResultHasOutput(mode string, result *protocol.Result) bool { if result == nil { return false } switch mode { case "text": return strings.TrimSpace(result.Text) != "" case "image": return len(result.Images) > 0 case "video": return len(result.Videos) > 0 case "audio": return len(result.Audios) > 0 default: return false } } func protocolMediaBytes(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { var data []byte var mimeType string var err error for attempt := 0; attempt < 3; attempt++ { data, mimeType, err = protocolMediaBytesOnce(ctx, config, reference) if err == nil { return data, mimeType, nil } if attempt == 2 || !retryableProtocolMediaDownload(err) { break } if waitErr := sleepContext(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil { return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", waitErr) } } return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", err) } func protocolMediaBytesOnce(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { if strings.TrimSpace(reference.DataURL) != "" { mimeType, data, err := decodeProviderDataURL(reference.DataURL) return data, mimeType, err } value := strings.TrimSpace(reference.URL) if value == "" { return nil, "", errors.New("声明式协议媒体结果地址为空") } data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) return data, normalizedMediaMimeType(mimeType, data), err } func retryableProtocolMediaDownload(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } var networkError net.Error if errors.As(err, &networkError) && (networkError.Timeout() || networkError.Temporary()) { return true } message := strings.ToLower(err.Error()) for _, marker := range []string{"tls handshake timeout", "connection reset", "unexpected eof", "broken pipe"} { if strings.Contains(message, marker) { return true } } return false } func protocolResultError(message, taskID string) error { message = strings.TrimSpace(message) if message == "" { message = "上游返回失败状态" } if taskID == "" { return errors.New(message) } return fmt.Errorf("声明式协议任务失败(任务 %s):%s", taskID, message) } func validateGenerationInterface(mode string, interfaceType string) error { // Standalone callers (including legacy tests and migration tools) may not // have a Service/plugin runtime. Use the shipped declarative catalog first; // fall back to host builtins only when the package catalog is unavailable. registry := loadOfficialFallbackRegistry() if registry == nil { registry = protocol.Builtins() } else if _, ok := registry.Resolve(strings.TrimSpace(interfaceType)); !ok { // A deployment may ship only a subset of the optional plugin catalog. // Preserve the host-backed builtins for standalone validation instead of // reporting a false "not installed" error for those interfaces. if _, builtinOK := protocol.Builtins().Resolve(strings.TrimSpace(interfaceType)); builtinOK { registry = protocol.Builtins() } } return validateGenerationInterfaceWithRegistry(registry, mode, interfaceType) } func (s *Service) validateGenerationInterface(mode string, interfaceType string) error { return validateGenerationInterfaceWithRegistry(s.protocolRegistry(), mode, interfaceType) } func validateGenerationInterfaceWithRegistry(registry *protocol.Registry, mode string, interfaceType string) error { interfaceType = strings.TrimSpace(interfaceType) if interfaceType == "" { return nil } adapter, ok := registry.Resolve(interfaceType) if !ok { return fmt.Errorf("接口类型 %s 未安装", interfaceType) } metadata := adapter.Metadata() if !metadata.Enabled || metadata.UnavailableReason != "" { return fmt.Errorf("接口类型 %s 当前不可用:%s", interfaceType, metadata.UnavailableReason) } if mode != "" && !protocolCapabilityMatches(metadata, protocol.Capability(mode)) { return fmt.Errorf("接口类型 %s 不支持%s生成", interfaceType, mode) } return nil } func protocolCapabilityMatches(metadata protocol.Metadata, capability protocol.Capability) bool { for _, item := range metadata.Categories { if item == capability { return true } } return false }