refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试
This commit is contained in:
1 parent
199e9190dc
commit
b1d854ee51
186 files changed
+33727
-29905
No files matched your search
@@ -21,12 +21,14 @@ type builtinAdapter struct {
|
||||
}
|
||||
|
||||
func (a builtinAdapter) Metadata() Metadata { return a.info }
|
||||
|
||||
func (a builtinAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) {
|
||||
if a.create == nil {
|
||||
return RequestSpec{}, unavailable(a.info)
|
||||
}
|
||||
return a.create(c.Request)
|
||||
}
|
||||
|
||||
func (a builtinAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) {
|
||||
if a.parseCreate == nil {
|
||||
return CreateResult{}, unavailable(a.info)
|
||||
@@ -37,12 +39,14 @@ func (a builtinAdapter) ParseCreate(_ context.Context, body []byte) (CreateResul
|
||||
}
|
||||
return a.parseCreate(payload)
|
||||
}
|
||||
|
||||
func (a builtinAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.poll == nil {
|
||||
return RequestSpec{}, unavailable(a.info)
|
||||
}
|
||||
return a.poll(c)
|
||||
}
|
||||
|
||||
func (a builtinAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) {
|
||||
if a.parsePoll == nil {
|
||||
return PollResult{}, unavailable(a.info)
|
||||
@@ -53,6 +57,7 @@ func (a builtinAdapter) ParsePoll(_ context.Context, c PollContext, body []byte)
|
||||
}
|
||||
return a.parsePoll(c, payload)
|
||||
}
|
||||
|
||||
func (a builtinAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.cancel == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.info.ID)
|
||||
@@ -61,6 +66,7 @@ func (a builtinAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSp
|
||||
}
|
||||
|
||||
var builtinRegistry *Registry
|
||||
|
||||
var builtinRegistryMu sync.Mutex
|
||||
|
||||
// Builtins returns the immutable process-wide registry of shipped protocol plugins.
|
||||
@@ -402,299 +408,6 @@ func miniMaxVideosAdapter() Adapter {
|
||||
})
|
||||
}
|
||||
|
||||
func agnesAdapter() Adapter {
|
||||
info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json")
|
||||
info.Version = "1.2.0"
|
||||
info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。"
|
||||
info.RequiresPublicMediaURLs = true
|
||||
info.Parameters = []Parameter{
|
||||
{Name: "model", Type: "string", Required: true, Mapping: "model", Description: "官方模型 ID:agnes-video-v2.0、agnes-video-2.5 或 agnes-video-2.5-flash。"},
|
||||
{Name: "prompt", Type: "string", Required: true, Mapping: "prompt", Description: "视频生成提示词。"},
|
||||
{Name: "images", Type: "media[]", Mapping: "first_frame/last_frame/images/image/extra_body.image", Description: "2.5 系列按 mode 映射为关键帧或参考图;V2.0 映射为 image 或关键帧数组。"},
|
||||
{Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"},
|
||||
{Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"},
|
||||
{Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"},
|
||||
{Name: "aspectRatio", Type: "string", Values: []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, Mapping: "aspect_ratio", Description: "2.5 系列画幅比例;历史像素尺寸会在插件边界转换为对应比例。"},
|
||||
{Name: "resolution", Type: "string", Values: []string{"720P", "960P", "2K"}, Mapping: "size", Description: "通用层分辨率档位,映射为 Agnes 的 size;Flash 固定 720P。"},
|
||||
}
|
||||
return builtinAdapter{
|
||||
info: info,
|
||||
create: buildAgnesCreate,
|
||||
parseCreate: parseAgnesCreate,
|
||||
poll: func(c PollContext) (RequestSpec, error) {
|
||||
model := strings.TrimSpace(c.Model)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(c.Request.Model)
|
||||
}
|
||||
path := "/agnesapi?video_id=" + url.QueryEscape(c.TaskID)
|
||||
if model != "" {
|
||||
path += "&model_name=" + url.QueryEscape(model)
|
||||
}
|
||||
return RequestSpec{Method: http.MethodGet, Path: path, OriginPath: true}, nil
|
||||
},
|
||||
parsePoll: parseAgnesPoll,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) {
|
||||
model := strings.TrimSpace(r.Model)
|
||||
switch model {
|
||||
case "agnes-video-v2.0":
|
||||
body, err := buildAgnesV20Body(r)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return jsonSpec(http.MethodPost, "/v1/videos", body), nil
|
||||
case "agnes-video-2.5", "agnes-video-2.5-flash":
|
||||
body, err := buildAgnesV25Body(r)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return jsonSpec(http.MethodPost, "/v1/videos", body), nil
|
||||
default:
|
||||
return RequestSpec{}, fmt.Errorf("Agnes 不支持模型 %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) {
|
||||
frameImages, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if len(frameImages) > 0 && (len(referenceImages) > 0 || len(r.Videos)+len(r.Audios) > 0) {
|
||||
return nil, fmt.Errorf("Agnes 2.5 不能同时混用首尾帧和角色、视频或音频参考素材")
|
||||
}
|
||||
mode := firstString(r.Extra, "mode")
|
||||
if mode == "" {
|
||||
switch strings.TrimSpace(r.Operation) {
|
||||
case "reference_to_video", "audio_to_video":
|
||||
mode = "reference"
|
||||
case "image_to_video":
|
||||
mode = "keyframe"
|
||||
default:
|
||||
switch {
|
||||
case len(r.Videos) > 0 || len(r.Audios) > 0 || len(referenceImages) > 0:
|
||||
mode = "reference"
|
||||
case len(frameImages) > 0:
|
||||
mode = "keyframe"
|
||||
case len(r.Images) > 2:
|
||||
mode = "reference"
|
||||
case len(r.Images) > 0:
|
||||
mode = "keyframe"
|
||||
default:
|
||||
mode = "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode != "text" && mode != "keyframe" && mode != "reference" {
|
||||
return nil, fmt.Errorf("Agnes 2.5 mode 必须是 text、keyframe 或 reference")
|
||||
}
|
||||
seconds := defaultInt(r.Duration, 5)
|
||||
if seconds < 4 || seconds > 12 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间")
|
||||
}
|
||||
size, err := normalizeAgnesV25Resolution(defaultValue(r.Resolution, firstString(r.Extra, "size")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aspectRatio, err := normalizeAgnesV25AspectRatio(defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Model == "agnes-video-2.5-flash" {
|
||||
if size != "720P" {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 仅支持 720P")
|
||||
}
|
||||
if len(r.Images) > 5 {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 最多支持 5 张参考图")
|
||||
}
|
||||
if len(r.Videos) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 不支持参考视频")
|
||||
}
|
||||
} else if size != "720P" && size != "960P" && size != "2K" {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 size 必须是 720P、960P 或 2K")
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": r.Model,
|
||||
"prompt": r.Prompt,
|
||||
"mode": mode,
|
||||
"seconds": strconv.Itoa(seconds),
|
||||
"size": size,
|
||||
"aspect_ratio": aspectRatio,
|
||||
"n": 1,
|
||||
}
|
||||
if seed, ok := r.Extra["seed"]; ok {
|
||||
body["seed"] = seed
|
||||
}
|
||||
switch mode {
|
||||
case "text":
|
||||
if len(r.Images)+len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 text 模式不接受参考媒体")
|
||||
}
|
||||
case "keyframe":
|
||||
if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考")
|
||||
}
|
||||
_, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if len(referenceImages) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式不能混用角色或风格参考图")
|
||||
}
|
||||
frameImages := orderedFrameImages(r.Images)
|
||||
if len(frameImages) == 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式缺少首帧图片")
|
||||
}
|
||||
body["first_frame"] = mediaValue(frameImages[0])
|
||||
if len(frameImages) > 1 {
|
||||
body["last_frame"] = mediaValue(frameImages[1])
|
||||
}
|
||||
case "reference":
|
||||
if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 reference 模式至少需要一种参考媒体")
|
||||
}
|
||||
if len(r.Images) > 0 {
|
||||
body["images"] = mediaValues(r.Images)
|
||||
}
|
||||
if len(r.Audios) > 0 {
|
||||
body["audios"] = mediaValues(r.Audios)
|
||||
}
|
||||
if len(r.Videos) > 0 {
|
||||
videos := make([]any, 0, len(r.Videos))
|
||||
for _, video := range r.Videos {
|
||||
videos = append(videos, map[string]any{"url": mediaValue(video)})
|
||||
}
|
||||
body["videos"] = videos
|
||||
}
|
||||
}
|
||||
return compactMap(body), nil
|
||||
}
|
||||
|
||||
func normalizeAgnesV25Resolution(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto", "720", "720p":
|
||||
return "720P", nil
|
||||
case "960", "960p":
|
||||
return "960P", nil
|
||||
case "2k", "1440", "1440p":
|
||||
return "2K", nil
|
||||
default:
|
||||
return "", fmt.Errorf("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAgnesV25AspectRatio(value string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
|
||||
if normalized == "" || normalized == "auto" {
|
||||
return "16:9", nil
|
||||
}
|
||||
ratios := []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
for _, ratio := range ratios {
|
||||
if normalized == ratio {
|
||||
return ratio, nil
|
||||
}
|
||||
}
|
||||
dimensions := strings.Split(normalized, "x")
|
||||
if len(dimensions) == 2 {
|
||||
width, widthErr := strconv.ParseInt(dimensions[0], 10, 64)
|
||||
height, heightErr := strconv.ParseInt(dimensions[1], 10, 64)
|
||||
if widthErr == nil && heightErr == nil && width > 0 && height > 0 && width <= 1_000_000 && height <= 1_000_000 {
|
||||
for _, ratio := range ratios {
|
||||
parts := strings.Split(ratio, ":")
|
||||
ratioWidth, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
ratioHeight, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
if width*ratioHeight == height*ratioWidth {
|
||||
return ratio, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16")
|
||||
}
|
||||
|
||||
func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) {
|
||||
if len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频")
|
||||
}
|
||||
_, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if r.Operation == "reference_to_video" || len(referenceImages) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5")
|
||||
}
|
||||
body := map[string]any{"model": r.Model, "prompt": r.Prompt}
|
||||
mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt")
|
||||
if r.Duration > 0 {
|
||||
frameRate := 24
|
||||
if value, ok := r.Extra["frame_rate"].(float64); ok && value >= 1 && value <= 60 {
|
||||
frameRate = int(value)
|
||||
}
|
||||
if value, ok := r.Extra["frame_rate"].(int); ok && value >= 1 && value <= 60 {
|
||||
frameRate = value
|
||||
}
|
||||
if _, supplied := r.Extra["frame_rate"]; !supplied {
|
||||
body["frame_rate"] = frameRate
|
||||
}
|
||||
if _, supplied := r.Extra["num_frames"]; !supplied {
|
||||
target := r.Duration * frameRate
|
||||
frames := ((target-1+4)/8)*8 + 1
|
||||
if frames > 441 {
|
||||
frames = 441
|
||||
}
|
||||
body["num_frames"] = frames
|
||||
}
|
||||
}
|
||||
orderedImages := orderedFrameImages(r.Images)
|
||||
if len(orderedImages) == 1 {
|
||||
body["image"] = mediaValue(orderedImages[0])
|
||||
} else if len(orderedImages) > 1 {
|
||||
extraBody := map[string]any{}
|
||||
if configured := object(r.Extra["extra_body"]); configured != nil {
|
||||
for key, value := range configured {
|
||||
extraBody[key] = value
|
||||
}
|
||||
}
|
||||
extraBody["image"] = mediaValues(orderedImages)
|
||||
extraBody["mode"] = "keyframes"
|
||||
body["extra_body"] = extraBody
|
||||
} else if configured := object(r.Extra["extra_body"]); configured != nil {
|
||||
body["extra_body"] = configured
|
||||
}
|
||||
return compactMap(body), nil
|
||||
}
|
||||
|
||||
func parseAgnesCreate(payload map[string]any) (CreateResult, error) {
|
||||
id := firstString(payload, "video_id", "task_id", "id")
|
||||
if id == "" {
|
||||
return CreateResult{}, fmt.Errorf("Agnes 创建响应没有 video_id")
|
||||
}
|
||||
status := normalizeStatus(firstString(payload, "status"))
|
||||
if status == "" {
|
||||
status = StatusPending
|
||||
}
|
||||
return CreateResult{TaskID: id, Status: status}, nil
|
||||
}
|
||||
|
||||
func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) {
|
||||
id := defaultValue(firstString(payload, "video_id", "task_id", "id"), c.TaskID)
|
||||
status := normalizeStatus(firstString(payload, "status"))
|
||||
if status == "" {
|
||||
status = StatusProcessing
|
||||
}
|
||||
message := firstString(payload, "message", "detail")
|
||||
if failure := object(payload["error"]); failure != nil {
|
||||
message = defaultValue(firstString(failure, "message", "detail", "code"), message)
|
||||
}
|
||||
if status == StatusFailed {
|
||||
return PollResult{TaskID: id, Status: status, Message: defaultValue(message, "Agnes 视频生成失败")}, nil
|
||||
}
|
||||
if status != StatusSucceeded {
|
||||
return PollResult{TaskID: id, Status: status, Message: message}, nil
|
||||
}
|
||||
metadata := object(payload["metadata"])
|
||||
videoURL := firstString(metadata, "url")
|
||||
if videoURL == "" {
|
||||
videoURL = firstString(payload, "url")
|
||||
}
|
||||
if videoURL == "" {
|
||||
return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL")
|
||||
}
|
||||
return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil
|
||||
}
|
||||
|
||||
func videoAdapter(info Metadata, create func(GenerationRequest) (RequestSpec, error)) Adapter {
|
||||
return asyncMediaAdapterWithPoll(info, CapabilityVideo, create, func(c PollContext) (RequestSpec, error) {
|
||||
path := strings.TrimSpace(info.Poll)
|
||||
@@ -807,6 +520,7 @@ func parseJiMengCreate(payload map[string]any) (CreateResult, error) {
|
||||
}
|
||||
return CreateResult{TaskID: id, Status: StatusPending}, nil
|
||||
}
|
||||
|
||||
func parseJiMengPoll(c PollContext, payload map[string]any) (PollResult, error) {
|
||||
return parseAsyncMediaPoll(c, payload, CapabilityImage)
|
||||
}
|
||||
@@ -965,7 +679,9 @@ func decodeObject(body []byte) (map[string]any, error) {
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func object(value any) map[string]any { result, _ := value.(map[string]any); return result }
|
||||
|
||||
func mergeMaps(primary, fallback map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(fallback)+len(primary))
|
||||
for k, v := range fallback {
|
||||
@@ -976,6 +692,7 @@ func mergeMaps(primary, fallback map[string]any) map[string]any {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstString(payload map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
@@ -984,23 +701,27 @@ func firstString(payload map[string]any, keys ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultValue(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultInt(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func copyIf(body map[string]any, key, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func mergeExtra(body map[string]any, extra map[string]any, keys ...string) {
|
||||
for _, key := range keys {
|
||||
if value, ok := extra[key]; ok {
|
||||
@@ -1008,6 +729,7 @@ func mergeExtra(body map[string]any, extra map[string]any, keys ...string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compactMap(body map[string]any) map[string]any {
|
||||
result := make(map[string]any)
|
||||
for key, value := range body {
|
||||
@@ -1029,7 +751,9 @@ func compactMap(body map[string]any) map[string]any {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mediaValue(media MediaReference) string { return defaultValue(media.URL, media.DataURL) }
|
||||
|
||||
func mediaRole(media MediaReference, fallback string) string {
|
||||
return defaultValue(strings.TrimSpace(media.Role), fallback)
|
||||
}
|
||||
@@ -1080,6 +804,7 @@ func mediaValues(values []MediaReference) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mediaFromArray(value any, capability Capability) []MediaReference {
|
||||
values, _ := value.([]any)
|
||||
result := make([]MediaReference, 0, len(values))
|
||||
@@ -1106,12 +831,14 @@ func mediaFromArray(value any, capability Capability) []MediaReference {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dataMIME(value string) string {
|
||||
if index := strings.Index(value, ";"); strings.HasPrefix(value, "data:") && index > 5 {
|
||||
return strings.TrimPrefix(value[5:index], "")
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func dataPayload(value string) string {
|
||||
if index := strings.Index(value, ","); strings.HasPrefix(value, "data:") && index >= 0 {
|
||||
return value[index+1:]
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// Agnes 视频模型的内置适配:v2.0 / v2.5 两代请求体与分辨率、比例规范化。
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func agnesAdapter() Adapter {
|
||||
info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json")
|
||||
info.Version = "1.2.0"
|
||||
info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。"
|
||||
info.RequiresPublicMediaURLs = true
|
||||
info.Parameters = []Parameter{
|
||||
{Name: "model", Type: "string", Required: true, Mapping: "model", Description: "官方模型 ID:agnes-video-v2.0、agnes-video-2.5 或 agnes-video-2.5-flash。"},
|
||||
{Name: "prompt", Type: "string", Required: true, Mapping: "prompt", Description: "视频生成提示词。"},
|
||||
{Name: "images", Type: "media[]", Mapping: "first_frame/last_frame/images/image/extra_body.image", Description: "2.5 系列按 mode 映射为关键帧或参考图;V2.0 映射为 image 或关键帧数组。"},
|
||||
{Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"},
|
||||
{Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"},
|
||||
{Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"},
|
||||
{Name: "aspectRatio", Type: "string", Values: []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, Mapping: "aspect_ratio", Description: "2.5 系列画幅比例;历史像素尺寸会在插件边界转换为对应比例。"},
|
||||
{Name: "resolution", Type: "string", Values: []string{"720P", "960P", "2K"}, Mapping: "size", Description: "通用层分辨率档位,映射为 Agnes 的 size;Flash 固定 720P。"},
|
||||
}
|
||||
return builtinAdapter{
|
||||
info: info,
|
||||
create: buildAgnesCreate,
|
||||
parseCreate: parseAgnesCreate,
|
||||
poll: func(c PollContext) (RequestSpec, error) {
|
||||
model := strings.TrimSpace(c.Model)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(c.Request.Model)
|
||||
}
|
||||
path := "/agnesapi?video_id=" + url.QueryEscape(c.TaskID)
|
||||
if model != "" {
|
||||
path += "&model_name=" + url.QueryEscape(model)
|
||||
}
|
||||
return RequestSpec{Method: http.MethodGet, Path: path, OriginPath: true}, nil
|
||||
},
|
||||
parsePoll: parseAgnesPoll,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) {
|
||||
model := strings.TrimSpace(r.Model)
|
||||
switch model {
|
||||
case "agnes-video-v2.0":
|
||||
body, err := buildAgnesV20Body(r)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return jsonSpec(http.MethodPost, "/v1/videos", body), nil
|
||||
case "agnes-video-2.5", "agnes-video-2.5-flash":
|
||||
body, err := buildAgnesV25Body(r)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return jsonSpec(http.MethodPost, "/v1/videos", body), nil
|
||||
default:
|
||||
return RequestSpec{}, fmt.Errorf("Agnes 不支持模型 %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) {
|
||||
frameImages, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if len(frameImages) > 0 && (len(referenceImages) > 0 || len(r.Videos)+len(r.Audios) > 0) {
|
||||
return nil, fmt.Errorf("Agnes 2.5 不能同时混用首尾帧和角色、视频或音频参考素材")
|
||||
}
|
||||
mode := firstString(r.Extra, "mode")
|
||||
if mode == "" {
|
||||
switch strings.TrimSpace(r.Operation) {
|
||||
case "reference_to_video", "audio_to_video":
|
||||
mode = "reference"
|
||||
case "image_to_video":
|
||||
mode = "keyframe"
|
||||
default:
|
||||
switch {
|
||||
case len(r.Videos) > 0 || len(r.Audios) > 0 || len(referenceImages) > 0:
|
||||
mode = "reference"
|
||||
case len(frameImages) > 0:
|
||||
mode = "keyframe"
|
||||
case len(r.Images) > 2:
|
||||
mode = "reference"
|
||||
case len(r.Images) > 0:
|
||||
mode = "keyframe"
|
||||
default:
|
||||
mode = "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode != "text" && mode != "keyframe" && mode != "reference" {
|
||||
return nil, fmt.Errorf("Agnes 2.5 mode 必须是 text、keyframe 或 reference")
|
||||
}
|
||||
seconds := defaultInt(r.Duration, 5)
|
||||
if seconds < 4 || seconds > 12 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间")
|
||||
}
|
||||
size, err := normalizeAgnesV25Resolution(defaultValue(r.Resolution, firstString(r.Extra, "size")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aspectRatio, err := normalizeAgnesV25AspectRatio(defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Model == "agnes-video-2.5-flash" {
|
||||
if size != "720P" {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 仅支持 720P")
|
||||
}
|
||||
if len(r.Images) > 5 {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 最多支持 5 张参考图")
|
||||
}
|
||||
if len(r.Videos) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 Flash 不支持参考视频")
|
||||
}
|
||||
} else if size != "720P" && size != "960P" && size != "2K" {
|
||||
return nil, fmt.Errorf("Agnes Video 2.5 size 必须是 720P、960P 或 2K")
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": r.Model,
|
||||
"prompt": r.Prompt,
|
||||
"mode": mode,
|
||||
"seconds": strconv.Itoa(seconds),
|
||||
"size": size,
|
||||
"aspect_ratio": aspectRatio,
|
||||
"n": 1,
|
||||
}
|
||||
if seed, ok := r.Extra["seed"]; ok {
|
||||
body["seed"] = seed
|
||||
}
|
||||
switch mode {
|
||||
case "text":
|
||||
if len(r.Images)+len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 text 模式不接受参考媒体")
|
||||
}
|
||||
case "keyframe":
|
||||
if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考")
|
||||
}
|
||||
_, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if len(referenceImages) > 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式不能混用角色或风格参考图")
|
||||
}
|
||||
frameImages := orderedFrameImages(r.Images)
|
||||
if len(frameImages) == 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式缺少首帧图片")
|
||||
}
|
||||
body["first_frame"] = mediaValue(frameImages[0])
|
||||
if len(frameImages) > 1 {
|
||||
body["last_frame"] = mediaValue(frameImages[1])
|
||||
}
|
||||
case "reference":
|
||||
if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 {
|
||||
return nil, fmt.Errorf("Agnes 2.5 reference 模式至少需要一种参考媒体")
|
||||
}
|
||||
if len(r.Images) > 0 {
|
||||
body["images"] = mediaValues(r.Images)
|
||||
}
|
||||
if len(r.Audios) > 0 {
|
||||
body["audios"] = mediaValues(r.Audios)
|
||||
}
|
||||
if len(r.Videos) > 0 {
|
||||
videos := make([]any, 0, len(r.Videos))
|
||||
for _, video := range r.Videos {
|
||||
videos = append(videos, map[string]any{"url": mediaValue(video)})
|
||||
}
|
||||
body["videos"] = videos
|
||||
}
|
||||
}
|
||||
return compactMap(body), nil
|
||||
}
|
||||
|
||||
func normalizeAgnesV25Resolution(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto", "720", "720p":
|
||||
return "720P", nil
|
||||
case "960", "960p":
|
||||
return "960P", nil
|
||||
case "2k", "1440", "1440p":
|
||||
return "2K", nil
|
||||
default:
|
||||
return "", fmt.Errorf("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAgnesV25AspectRatio(value string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
|
||||
if normalized == "" || normalized == "auto" {
|
||||
return "16:9", nil
|
||||
}
|
||||
ratios := []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
for _, ratio := range ratios {
|
||||
if normalized == ratio {
|
||||
return ratio, nil
|
||||
}
|
||||
}
|
||||
dimensions := strings.Split(normalized, "x")
|
||||
if len(dimensions) == 2 {
|
||||
width, widthErr := strconv.ParseInt(dimensions[0], 10, 64)
|
||||
height, heightErr := strconv.ParseInt(dimensions[1], 10, 64)
|
||||
if widthErr == nil && heightErr == nil && width > 0 && height > 0 && width <= 1_000_000 && height <= 1_000_000 {
|
||||
for _, ratio := range ratios {
|
||||
parts := strings.Split(ratio, ":")
|
||||
ratioWidth, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
ratioHeight, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
if width*ratioHeight == height*ratioWidth {
|
||||
return ratio, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16")
|
||||
}
|
||||
|
||||
func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) {
|
||||
if len(r.Videos)+len(r.Audios) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频")
|
||||
}
|
||||
_, referenceImages, _ := splitVideoImages(r.Images)
|
||||
if r.Operation == "reference_to_video" || len(referenceImages) > 0 {
|
||||
return nil, fmt.Errorf("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5")
|
||||
}
|
||||
body := map[string]any{"model": r.Model, "prompt": r.Prompt}
|
||||
mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt")
|
||||
if r.Duration > 0 {
|
||||
frameRate := 24
|
||||
if value, ok := r.Extra["frame_rate"].(float64); ok && value >= 1 && value <= 60 {
|
||||
frameRate = int(value)
|
||||
}
|
||||
if value, ok := r.Extra["frame_rate"].(int); ok && value >= 1 && value <= 60 {
|
||||
frameRate = value
|
||||
}
|
||||
if _, supplied := r.Extra["frame_rate"]; !supplied {
|
||||
body["frame_rate"] = frameRate
|
||||
}
|
||||
if _, supplied := r.Extra["num_frames"]; !supplied {
|
||||
target := r.Duration * frameRate
|
||||
frames := ((target-1+4)/8)*8 + 1
|
||||
if frames > 441 {
|
||||
frames = 441
|
||||
}
|
||||
body["num_frames"] = frames
|
||||
}
|
||||
}
|
||||
orderedImages := orderedFrameImages(r.Images)
|
||||
if len(orderedImages) == 1 {
|
||||
body["image"] = mediaValue(orderedImages[0])
|
||||
} else if len(orderedImages) > 1 {
|
||||
extraBody := map[string]any{}
|
||||
if configured := object(r.Extra["extra_body"]); configured != nil {
|
||||
for key, value := range configured {
|
||||
extraBody[key] = value
|
||||
}
|
||||
}
|
||||
extraBody["image"] = mediaValues(orderedImages)
|
||||
extraBody["mode"] = "keyframes"
|
||||
body["extra_body"] = extraBody
|
||||
} else if configured := object(r.Extra["extra_body"]); configured != nil {
|
||||
body["extra_body"] = configured
|
||||
}
|
||||
return compactMap(body), nil
|
||||
}
|
||||
|
||||
func parseAgnesCreate(payload map[string]any) (CreateResult, error) {
|
||||
id := firstString(payload, "video_id", "task_id", "id")
|
||||
if id == "" {
|
||||
return CreateResult{}, fmt.Errorf("Agnes 创建响应没有 video_id")
|
||||
}
|
||||
status := normalizeStatus(firstString(payload, "status"))
|
||||
if status == "" {
|
||||
status = StatusPending
|
||||
}
|
||||
return CreateResult{TaskID: id, Status: status}, nil
|
||||
}
|
||||
|
||||
func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) {
|
||||
id := defaultValue(firstString(payload, "video_id", "task_id", "id"), c.TaskID)
|
||||
status := normalizeStatus(firstString(payload, "status"))
|
||||
if status == "" {
|
||||
status = StatusProcessing
|
||||
}
|
||||
message := firstString(payload, "message", "detail")
|
||||
if failure := object(payload["error"]); failure != nil {
|
||||
message = defaultValue(firstString(failure, "message", "detail", "code"), message)
|
||||
}
|
||||
if status == StatusFailed {
|
||||
return PollResult{TaskID: id, Status: status, Message: defaultValue(message, "Agnes 视频生成失败")}, nil
|
||||
}
|
||||
if status != StatusSucceeded {
|
||||
return PollResult{TaskID: id, Status: status, Message: message}, nil
|
||||
}
|
||||
metadata := object(payload["metadata"])
|
||||
videoURL := firstString(metadata, "url")
|
||||
if videoURL == "" {
|
||||
videoURL = firstString(payload, "url")
|
||||
}
|
||||
if videoURL == "" {
|
||||
return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL")
|
||||
}
|
||||
return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil
|
||||
}
|
||||
@@ -2,14 +2,10 @@ package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -162,29 +158,37 @@ type metadataAdapter struct {
|
||||
}
|
||||
|
||||
func (a metadataAdapter) Metadata() Metadata { return a.metadata }
|
||||
|
||||
func (a metadataAdapter) AgentAvailable() bool {
|
||||
capability, ok := a.delegate.(AgentCapability)
|
||||
return ok && capability.AgentAvailable()
|
||||
}
|
||||
|
||||
func (a metadataAdapter) ResultAvailable() bool {
|
||||
capability, ok := a.delegate.(ResultCapability)
|
||||
return ok && capability.ResultAvailable()
|
||||
}
|
||||
|
||||
func (a metadataAdapter) BuildCreate(ctx context.Context, c RequestContext) (RequestSpec, error) {
|
||||
return a.delegate.BuildCreate(ctx, c)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) ParseCreate(ctx context.Context, body []byte) (CreateResult, error) {
|
||||
return a.delegate.ParseCreate(ctx, body)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) BuildPoll(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||||
return a.delegate.BuildPoll(ctx, c)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) ParsePoll(ctx context.Context, c PollContext, body []byte) (PollResult, error) {
|
||||
return a.delegate.ParsePoll(ctx, c, body)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) BuildCancel(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||||
return a.delegate.BuildCancel(ctx, c)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) BuildResult(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||||
adapter, ok := a.delegate.(ResultAdapter)
|
||||
if !ok {
|
||||
@@ -192,6 +196,7 @@ func (a metadataAdapter) BuildResult(ctx context.Context, c PollContext) (Reques
|
||||
}
|
||||
return adapter.BuildResult(ctx, c)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) BuildAgent(ctx context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||||
adapter, ok := a.delegate.(AgentAdapter)
|
||||
if !ok {
|
||||
@@ -199,6 +204,7 @@ func (a metadataAdapter) BuildAgent(ctx context.Context, c AgentRequestContext)
|
||||
}
|
||||
return adapter.BuildAgent(ctx, c)
|
||||
}
|
||||
|
||||
func (a metadataAdapter) ParseAgent(ctx context.Context, body []byte) (AgentResult, error) {
|
||||
adapter, ok := a.delegate.(AgentAdapter)
|
||||
if !ok {
|
||||
@@ -453,890 +459,6 @@ func validateManifestOperation(operation ManifestOperation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type manifestAdapter struct{ manifest Manifest }
|
||||
|
||||
func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata }
|
||||
func (a manifestAdapter) AgentAvailable() bool {
|
||||
return a.manifest.Agent != nil && a.manifest.AgentResponse != nil
|
||||
}
|
||||
func (a manifestAdapter) ResultAvailable() bool { return a.manifest.ResultOperation != nil }
|
||||
func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) {
|
||||
if len(a.manifest.Contributes.Providers) == 0 {
|
||||
return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID)
|
||||
}
|
||||
if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "")
|
||||
}
|
||||
func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||||
if a.manifest.Agent == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no agent operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := GenerationRequest{Model: c.Model, Extra: map[string]any{"agent": c.Request}}
|
||||
return buildManifestOperation(*a.manifest.Agent, a.manifest.Auth, request, "")
|
||||
}
|
||||
func (a manifestAdapter) ParseAgent(_ context.Context, body []byte) (AgentResult, error) {
|
||||
if a.manifest.AgentResponse == nil {
|
||||
return AgentResult{}, fmt.Errorf("protocol %s has no agent response mapping", a.manifest.Metadata.ID)
|
||||
}
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return AgentResult{}, err
|
||||
}
|
||||
response := a.manifest.AgentResponse
|
||||
result := AgentResult{Text: firstPathValue(payload, response.TextPaths...), Reasoning: firstPathValue(payload, response.ReasoningPaths...)}
|
||||
if response.ToolCallsPath == "" {
|
||||
return result, nil
|
||||
}
|
||||
for index, item := range arrayValue(pathValue(payload, response.ToolCallsPath)) {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
call := AgentToolCall{
|
||||
ID: firstPathValue(object, response.ToolCallIDPaths...),
|
||||
Name: firstPathValue(object, response.ToolCallNamePaths...),
|
||||
Arguments: firstPathJSONValue(object, response.ToolCallArgsPaths...),
|
||||
ThoughtSignature: firstPathValue(object, response.ToolCallSignaturePaths...),
|
||||
}
|
||||
if strings.TrimSpace(call.Name) != "" {
|
||||
if strings.TrimSpace(call.ID) == "" {
|
||||
call.ID = syntheticAgentToolCallID(body, index)
|
||||
}
|
||||
result.ToolCalls = append(result.ToolCalls, call)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func syntheticAgentToolCallID(body []byte, index int) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", body, index)))
|
||||
return fmt.Sprintf("call_%x", sum[:8])
|
||||
}
|
||||
func (a manifestAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) {
|
||||
if a.manifest.Response.BinaryPayload {
|
||||
return binaryPayloadCreateResult(a.manifest.Response.ResultKind, body)
|
||||
}
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
return a.parse(payload, PollContext{}), nil
|
||||
}
|
||||
|
||||
// binaryPayloadCreateResult 把同步二进制响应包装为单个媒体结果。MIME 以响应内容探测为准,
|
||||
// 空响应必须失败,不能把空内容伪装成生成成功。
|
||||
func binaryPayloadCreateResult(resultKind string, body []byte) (CreateResult, error) {
|
||||
if len(body) == 0 {
|
||||
return CreateResult{}, fmt.Errorf("binary payload response is empty")
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(body), ";")[0]))
|
||||
reference := MediaReference{DataURL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(body), MIMEType: mimeType}
|
||||
result := &Result{}
|
||||
switch resultKind {
|
||||
case "audio":
|
||||
result.Audios = []MediaReference{reference}
|
||||
case "image":
|
||||
result.Images = []MediaReference{reference}
|
||||
case "video":
|
||||
result.Videos = []MediaReference{reference}
|
||||
default:
|
||||
return CreateResult{}, fmt.Errorf("binary payload response requires resultKind image, video or audio, got %q", resultKind)
|
||||
}
|
||||
return CreateResult{Status: StatusSucceeded, Result: result}, nil
|
||||
}
|
||||
func (a manifestAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.Poll == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no poll operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.Poll, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
func (a manifestAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) {
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return PollResult{}, err
|
||||
}
|
||||
result := a.parse(payload, c)
|
||||
return PollResult{TaskID: result.TaskID, Status: result.Status, Result: result.Result, Message: result.Message}, nil
|
||||
}
|
||||
func (a manifestAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.Cancel == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.Cancel, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
|
||||
func (a manifestAdapter) BuildResult(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.ResultOperation == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no result operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.ResultOperation, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
|
||||
func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResult {
|
||||
response := a.manifest.Response
|
||||
env := map[string]any{"response": payload, "taskId": c.TaskID, "request": manifestRequestValues(c.Request)}
|
||||
id := manifestResponseString(response.TaskID, env)
|
||||
if id == "" {
|
||||
id = firstPathValue(payload, response.TaskIDPaths...)
|
||||
}
|
||||
if id == "" {
|
||||
id = c.TaskID
|
||||
}
|
||||
statusText := manifestResponseString(response.Status, env)
|
||||
if statusText == "" {
|
||||
statusText = firstPathValue(payload, response.StatusPaths...)
|
||||
}
|
||||
status := normalizeStatus(statusText)
|
||||
if status == "" {
|
||||
status = StatusPending
|
||||
}
|
||||
message := manifestResponseString(response.Message, env)
|
||||
if message == "" {
|
||||
message = firstPathValue(payload, response.MessagePaths...)
|
||||
}
|
||||
if manifestError(payload, response.ErrorPaths...) {
|
||||
status = StatusFailed
|
||||
}
|
||||
result := &Result{
|
||||
Text: manifestResponseString(response.Text, env),
|
||||
Reasoning: manifestResponseString(response.Reasoning, env),
|
||||
}
|
||||
if result.Text == "" {
|
||||
result.Text = firstPathValue(payload, response.TextPaths...)
|
||||
}
|
||||
if result.Reasoning == "" {
|
||||
result.Reasoning = firstPathValue(payload, response.ReasoningPaths...)
|
||||
}
|
||||
result.Images = manifestResponseMedia(response.Images, env, "image", response.ResultEphemeral)
|
||||
result.Videos = manifestResponseMedia(response.Videos, env, "video", response.ResultEphemeral)
|
||||
result.Audios = manifestResponseMedia(response.Audios, env, "audio", response.ResultEphemeral)
|
||||
if response.Usage != nil {
|
||||
if value, err := evaluateManifestValue(response.Usage, env); err == nil {
|
||||
result.Usage = manifestObject(value)
|
||||
}
|
||||
}
|
||||
paths := append([]string{}, a.manifest.Response.ResultURLPaths...)
|
||||
paths = append(paths, a.manifest.Response.ResultPaths...)
|
||||
for _, path := range paths {
|
||||
for _, value := range mediaPathValues(payload, path) {
|
||||
item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral}
|
||||
switch a.manifest.Response.ResultKind {
|
||||
case "image":
|
||||
result.Images = append(result.Images, item)
|
||||
case "audio":
|
||||
result.Audios = append(result.Audios, item)
|
||||
default:
|
||||
result.Videos = append(result.Videos, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == StatusPending && (result.Text != "" || len(result.Images) > 0 || len(result.Videos) > 0 || len(result.Audios) > 0) {
|
||||
status = StatusSucceeded
|
||||
}
|
||||
if result.Text == "" && result.Reasoning == "" && len(result.Images) == 0 && len(result.Videos) == 0 && len(result.Audios) == 0 {
|
||||
result = nil
|
||||
}
|
||||
return CreateResult{TaskID: id, Status: status, Result: result, Message: message}
|
||||
}
|
||||
|
||||
func validateManifestRequest(rules []ManifestValidation, request GenerationRequest) error {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
env := map[string]any{"request": manifestRequestValues(request)}
|
||||
for _, rule := range rules {
|
||||
value, err := evaluateManifestValue(rule.Assert, env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("协议参数校验表达式错误:%w", err)
|
||||
}
|
||||
if !manifestTruthy(value) {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(rule.Message))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manifestResponseString(template any, env map[string]any) string {
|
||||
if template == nil {
|
||||
return ""
|
||||
}
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
items := manifestArray(value)
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func manifestResponseMedia(template any, env map[string]any, kind string, ephemeral bool) []MediaReference {
|
||||
if template == nil {
|
||||
return nil
|
||||
}
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return mediaReferencesFromManifestValue(value, kind, ephemeral)
|
||||
}
|
||||
|
||||
func mediaReferencesFromManifestValue(value any, kind string, ephemeral bool) []MediaReference {
|
||||
result := make([]MediaReference, 0)
|
||||
for _, item := range manifestArray(value) {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
reference := MediaReference{Kind: kind, Ephemeral: ephemeral}
|
||||
if strings.HasPrefix(trimmed, "data:") {
|
||||
reference.DataURL = trimmed
|
||||
} else {
|
||||
reference.URL = trimmed
|
||||
}
|
||||
result = append(result, reference)
|
||||
case map[string]any:
|
||||
reference := MediaReference{
|
||||
ID: manifestString(typed["id"]), URL: manifestString(typed["url"]), DataURL: manifestString(typed["dataUrl"]),
|
||||
Kind: defaultValue(manifestString(typed["kind"]), kind), Role: manifestString(typed["role"]), MIMEType: manifestString(typed["mimeType"]),
|
||||
Name: manifestString(typed["name"]), Order: manifestInt(typed["order"]), Weight: manifestFloat(typed["weight"]), Ephemeral: ephemeral || manifestTruthy(typed["ephemeral"]),
|
||||
}
|
||||
if reference.URL == "" {
|
||||
reference.URL = firstString(typed, "file_url", "fileUrl", "image_url", "imageUrl", "video_url", "videoUrl", "audio_url", "audioUrl", "uri")
|
||||
}
|
||||
if reference.DataURL == "" {
|
||||
reference.DataURL = firstString(typed, "data_url", "b64_json")
|
||||
}
|
||||
// OpenAI / Ark 等渠道常直接返回裸 b64_json;声明式结果下载要求 data URL。
|
||||
reference.DataURL = normalizeManifestInlineDataURL(reference.DataURL, reference.Kind, firstString(typed, "output_format", "mime_type", "mimeType"))
|
||||
if reference.URL != "" || reference.DataURL != "" {
|
||||
result = append(result, reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeManifestInlineDataURL(value, kind, formatHint string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "data:") {
|
||||
return value
|
||||
}
|
||||
return "data:" + manifestInlineMediaMIME(kind, formatHint) + ";base64," + value
|
||||
}
|
||||
|
||||
func manifestInlineMediaMIME(kind, formatHint string) string {
|
||||
hint := strings.ToLower(strings.TrimSpace(formatHint))
|
||||
switch hint {
|
||||
case "image/png", "image/jpeg", "image/webp", "image/gif", "audio/mpeg", "audio/wav", "audio/ogg", "video/mp4", "video/webm":
|
||||
return hint
|
||||
case "image/jpg":
|
||||
return "image/jpeg"
|
||||
case "audio/mp3":
|
||||
return "audio/mpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpeg", "jpg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "wav":
|
||||
return "audio/wav"
|
||||
case "mp4":
|
||||
return "video/mp4"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "audio":
|
||||
return "audio/mpeg"
|
||||
case "video":
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
manifestHTMLVideoRegex = regexp.MustCompile(`(?i)<video[^>]+src=['"]([^'"]+)['"]`)
|
||||
)
|
||||
|
||||
func mediaPathValues(payload map[string]any, path string) []string {
|
||||
value := pathValue(payload, path)
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||||
var res []string
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
res = append(res, m[1])
|
||||
}
|
||||
}
|
||||
if len(res) > 0 {
|
||||
return res
|
||||
}
|
||||
}
|
||||
if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||||
var res []string
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
res = append(res, m[1])
|
||||
}
|
||||
}
|
||||
if len(res) > 0 {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
values = append(values, strings.TrimSpace(typed))
|
||||
}
|
||||
case map[string]any:
|
||||
if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" {
|
||||
values = append(values, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func buildManifestOperation(operation ManifestOperation, auth ManifestAuth, request GenerationRequest, taskID string) (RequestSpec, error) {
|
||||
requestValues := manifestRequestValues(request)
|
||||
env := map[string]any{"request": requestValues, "taskId": taskID}
|
||||
var body any
|
||||
if operation.Body != nil {
|
||||
value, err := evaluateManifestValue(operation.Body, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request body: %w", err)
|
||||
}
|
||||
body = normalizeManifestValue(value)
|
||||
} else if len(operation.Fields) > 0 {
|
||||
legacyBody := make(map[string]any, len(operation.Fields))
|
||||
for key, expression := range operation.Fields {
|
||||
value := manifestExpressionValue(expression, request, taskID)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
setMapPath(legacyBody, key, value)
|
||||
}
|
||||
body = normalizeManifestValue(legacyBody)
|
||||
}
|
||||
pathValue := any(operation.Path)
|
||||
if operation.PathTemplate != nil {
|
||||
pathValue = operation.PathTemplate
|
||||
}
|
||||
evaluatedPath, err := evaluateManifestValue(pathValue, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request path: %w", err)
|
||||
}
|
||||
path := strings.ReplaceAll(manifestString(evaluatedPath), "{{taskId}}", url.PathEscape(taskID))
|
||||
// Model identifiers from async aggregators commonly contain path segments
|
||||
// (for example openai/gpt-image/edit). Escape each segment while preserving
|
||||
// the provider's intentional slash separators in the manifest path.
|
||||
escapedModel := strings.ReplaceAll(url.PathEscape(request.Model), "%2F", "/")
|
||||
path = strings.ReplaceAll(path, "{{model}}", escapedModel)
|
||||
path = interpolateManifestString(path, env)
|
||||
if !isRelativePath(path) {
|
||||
return RequestSpec{}, fmt.Errorf("evaluated request path must be relative: %q", path)
|
||||
}
|
||||
headers, err := evaluateManifestStringMap(operation.Headers, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request headers: %w", err)
|
||||
}
|
||||
query, err := evaluateManifestQuery(operation.Query, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request query: %w", err)
|
||||
}
|
||||
files, err := evaluateManifestFiles(operation.Files, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
contentTypeValue := any(operation.ContentType)
|
||||
if operation.ContentTypeTemplate != nil {
|
||||
contentTypeValue = operation.ContentTypeTemplate
|
||||
}
|
||||
evaluatedContentType, err := evaluateManifestValue(contentTypeValue, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request content type: %w", err)
|
||||
}
|
||||
contentType := defaultValue(manifestString(evaluatedContentType), "application/json")
|
||||
return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, OriginPath: operation.OriginPath, ContentType: contentType, Headers: headers, Query: query, Body: body, Files: files, Auth: auth}, nil
|
||||
}
|
||||
|
||||
func evaluateManifestStringMap(values map[string]any, env map[string]any) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for key, template := range values {
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if text := strings.TrimSpace(manifestString(value)); text != "" {
|
||||
result[key] = text
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func evaluateManifestQuery(values map[string]any, env map[string]any) (map[string][]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string][]string, len(values))
|
||||
for key, template := range values {
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range manifestArray(value) {
|
||||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||||
result[key] = append(result[key], text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func evaluateManifestFiles(parts []ManifestFilePart, env map[string]any) ([]RequestFilePart, error) {
|
||||
result := make([]RequestFilePart, 0)
|
||||
for _, part := range parts {
|
||||
source, err := evaluateManifestValue(part.Source, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart file %q: %w", part.Name, err)
|
||||
}
|
||||
filename, err := evaluateManifestValue(part.Filename, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart filename %q: %w", part.Name, err)
|
||||
}
|
||||
mimeType, err := evaluateManifestValue(part.MIMEType, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart MIME type %q: %w", part.Name, err)
|
||||
}
|
||||
for index, value := range manifestArray(source) {
|
||||
references := mediaReferencesFromManifestValue(value, "file", false)
|
||||
for _, reference := range references {
|
||||
name := strings.TrimSpace(manifestString(filename))
|
||||
if name == "" {
|
||||
name = reference.Name
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s-%d", part.Name, index+1)
|
||||
}
|
||||
contentType := strings.TrimSpace(manifestString(mimeType))
|
||||
if contentType == "" {
|
||||
contentType = reference.MIMEType
|
||||
}
|
||||
result = append(result, RequestFilePart{Name: part.Name, Filename: name, MIMEType: contentType, Reference: reference})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeManifestValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if len(v) == 0 {
|
||||
return v
|
||||
}
|
||||
isSequential := true
|
||||
for i := 0; i < len(v); i++ {
|
||||
if _, ok := v[strconv.Itoa(i)]; !ok {
|
||||
isSequential = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isSequential {
|
||||
arr := make([]any, len(v))
|
||||
for i := 0; i < len(v); i++ {
|
||||
arr[i] = normalizeManifestValue(v[strconv.Itoa(i)])
|
||||
}
|
||||
return arr
|
||||
}
|
||||
res := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
res[k] = normalizeManifestValue(val)
|
||||
}
|
||||
return res
|
||||
case []any:
|
||||
arr := make([]any, len(v))
|
||||
for i, val := range v {
|
||||
arr[i] = normalizeManifestValue(val)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any {
|
||||
expression = strings.TrimSpace(expression)
|
||||
source := expression
|
||||
transforms := []string{}
|
||||
if separator := strings.IndexByte(expression, '|'); separator >= 0 {
|
||||
source = strings.TrimSpace(expression[:separator])
|
||||
transforms = strings.Split(expression[separator+1:], "|")
|
||||
}
|
||||
|
||||
var value any
|
||||
switch {
|
||||
case source == "taskId":
|
||||
value = taskID
|
||||
case strings.HasPrefix(source, "request."):
|
||||
value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request."))
|
||||
default:
|
||||
value = source
|
||||
}
|
||||
for _, transform := range transforms {
|
||||
value = applyManifestTransform(value, transform, request)
|
||||
if value == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// manifestRequestValues is deliberately a small, JSON-shaped view of the
|
||||
// platform request. It lets uploaded manifests address media items by index
|
||||
// without exposing Go structs or adding provider-specific host code.
|
||||
func manifestRequestValues(request GenerationRequest) map[string]any {
|
||||
userContent := make([]any, 0)
|
||||
if strings.TrimSpace(request.Prompt) != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt})
|
||||
}
|
||||
for _, img := range request.Images {
|
||||
val := defaultValue(img.URL, img.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
for _, vid := range request.Videos {
|
||||
val := defaultValue(vid.URL, vid.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
for _, aud := range request.Audios {
|
||||
val := defaultValue(aud.URL, aud.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
messages := make([]any, 0, len(request.Messages)+2)
|
||||
// instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。
|
||||
// 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、
|
||||
// Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。
|
||||
if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||||
}
|
||||
for _, message := range request.Messages {
|
||||
if strings.TrimSpace(message.Role) == "" || message.Content == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": message.Role, "content": message.Content})
|
||||
}
|
||||
if len(userContent) > 0 {
|
||||
content := any(userContent)
|
||||
if len(userContent) == 1 && len(request.Images)+len(request.Videos)+len(request.Audios) == 0 {
|
||||
content = request.Prompt
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": "user", "content": content})
|
||||
}
|
||||
|
||||
inputs := request.Inputs
|
||||
if len(inputs) == 0 {
|
||||
inputs = append(inputs, request.Images...)
|
||||
inputs = append(inputs, request.Videos...)
|
||||
inputs = append(inputs, request.Audios...)
|
||||
}
|
||||
output := request.Output
|
||||
if output.Count == 0 {
|
||||
output.Count = request.ImageCount
|
||||
}
|
||||
if output.Duration == 0 {
|
||||
output.Duration = request.Duration
|
||||
}
|
||||
if output.AspectRatio == "" {
|
||||
output.AspectRatio = request.AspectRatio
|
||||
}
|
||||
if output.Resolution == "" {
|
||||
output.Resolution = request.Resolution
|
||||
}
|
||||
if output.Quality == "" {
|
||||
output.Quality = request.Quality
|
||||
}
|
||||
output.GenerateAudio = output.GenerateAudio || request.GenerateAudio
|
||||
output.Watermark = output.Watermark || request.Watermark
|
||||
outputValue, _ := requestAsManifestValue(output)
|
||||
providerOptionsValue, _ := requestAsManifestValue(request.ProviderOptions)
|
||||
if providerOptionsValue == nil {
|
||||
providerOptionsValue = map[string]any{}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"capability": request.Capability,
|
||||
"model": request.Model,
|
||||
"prompt": request.Prompt,
|
||||
"instructions": request.Instructions,
|
||||
"messages": messages,
|
||||
"inputs": manifestMediaValues(inputs),
|
||||
"images": manifestMediaValues(request.Images),
|
||||
"videos": manifestMediaValues(request.Videos),
|
||||
"audios": manifestMediaValues(request.Audios),
|
||||
"imageCount": request.ImageCount,
|
||||
"duration": request.Duration,
|
||||
"aspectRatio": request.AspectRatio,
|
||||
"resolution": request.Resolution,
|
||||
"quality": request.Quality,
|
||||
"generateAudio": request.GenerateAudio,
|
||||
"watermark": request.Watermark,
|
||||
"operation": request.Operation,
|
||||
"output": outputValue,
|
||||
"providerOptions": providerOptionsValue,
|
||||
"extra": request.Extra,
|
||||
}
|
||||
}
|
||||
|
||||
func hasSystemMessage(messages []Message) bool {
|
||||
for _, message := range messages {
|
||||
if strings.EqualFold(strings.TrimSpace(message.Role), "system") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requestAsManifestValue(value any) (any, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result any
|
||||
if err := json.Unmarshal(encoded, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func manifestModelID(request GenerationRequest) string {
|
||||
modelID := strings.TrimSpace(request.Model)
|
||||
if separator := strings.LastIndex(modelID, "::"); separator >= 0 {
|
||||
modelID = modelID[separator+2:]
|
||||
}
|
||||
return strings.ToLower(modelID)
|
||||
}
|
||||
|
||||
func manifestMediaValues(values []MediaReference) []any {
|
||||
result := make([]any, 0, len(values))
|
||||
for index, value := range values {
|
||||
order := value.Order
|
||||
if order == 0 && index > 0 {
|
||||
order = index
|
||||
}
|
||||
resolved := defaultValue(value.URL, value.DataURL)
|
||||
result = append(result, map[string]any{
|
||||
"id": value.ID,
|
||||
"url": value.URL,
|
||||
"dataUrl": value.DataURL,
|
||||
"value": resolved,
|
||||
"kind": value.Kind,
|
||||
"role": value.Role,
|
||||
"mimeType": value.MIMEType,
|
||||
"name": value.Name,
|
||||
"order": order,
|
||||
"weight": value.Weight,
|
||||
"metadata": value.Metadata,
|
||||
"source": map[string]any{
|
||||
"type": manifestMediaSourceType(value),
|
||||
"value": resolved,
|
||||
"mimeType": value.MIMEType,
|
||||
},
|
||||
"ephemeral": value.Ephemeral,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func manifestMediaSourceType(value MediaReference) string {
|
||||
if value.DataURL != "" {
|
||||
return "data"
|
||||
}
|
||||
if value.URL != "" {
|
||||
return "url"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func applyManifestTransform(value any, transform string, request GenerationRequest) any {
|
||||
transform = strings.ToLower(strings.TrimSpace(transform))
|
||||
if transform == "bool" || transform == "boolean" {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
return s == "true" || s == "1" || s == "yes"
|
||||
case int, int64, float64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if transform == "int" || transform == "integer" {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if transform == "omit_zero" {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case int64:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case float64:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanModel := manifestModelID(request)
|
||||
if strings.HasPrefix(transform, "omit_unless_model_contains:") {
|
||||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:")))
|
||||
if needle == "" || !strings.Contains(cleanModel, needle) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_if_model_contains:") {
|
||||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:")))
|
||||
if needle != "" && strings.Contains(cleanModel, needle) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_unless_model_equals:") {
|
||||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:")))
|
||||
if target == "" || cleanModel != target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_if_model_equals:") {
|
||||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:")))
|
||||
if target != "" && cleanModel == target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") {
|
||||
return nil
|
||||
}
|
||||
if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) {
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
switch transform {
|
||||
case "trim":
|
||||
return strings.TrimSpace(text)
|
||||
case "lower", "lowercase":
|
||||
return strings.ToLower(text)
|
||||
case "upper", "uppercase":
|
||||
return strings.ToUpper(text)
|
||||
case "resolution_p":
|
||||
if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) {
|
||||
return strings.TrimSpace(text) + "p"
|
||||
}
|
||||
return text
|
||||
case "video-resolution", "video_resolution", "videoresolution":
|
||||
// Compatibility for already-installed 1.0.1 manifests. New plugins
|
||||
// should declare exact enum values and use generic string transforms.
|
||||
return normalizeLegacyManifestVideoResolution(text)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyManifestVideoResolution(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
for _, char := range normalized {
|
||||
if char < '0' || char > '9' {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return normalized + "p"
|
||||
}
|
||||
|
||||
func pathValue(payload map[string]any, path string) any {
|
||||
return manifestPathValue(payload, path)
|
||||
}
|
||||
|
||||
func arrayValue(value any) []any {
|
||||
items, _ := value.([]any)
|
||||
return items
|
||||
}
|
||||
|
||||
func firstPathValue(payload map[string]any, paths ...string) string {
|
||||
for _, path := range paths {
|
||||
value := pathValue(payload, path)
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func manifestError(payload map[string]any, paths ...string) bool {
|
||||
for _, path := range paths {
|
||||
value := pathValue(payload, path)
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
// 声明式插件的运行期适配器(manifestAdapter):按清单组装请求、解析响应中的任务 ID、状态与媒体。
|
||||
//
|
||||
// 媒体提取同时支持 JSON 路径、Markdown 图片、HTML 视频标签与内联 data URL,
|
||||
// 兼容不同上游的返回形态。
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type manifestAdapter struct{ manifest Manifest }
|
||||
|
||||
func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata }
|
||||
|
||||
func (a manifestAdapter) AgentAvailable() bool {
|
||||
return a.manifest.Agent != nil && a.manifest.AgentResponse != nil
|
||||
}
|
||||
|
||||
func (a manifestAdapter) ResultAvailable() bool { return a.manifest.ResultOperation != nil }
|
||||
|
||||
func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) {
|
||||
if len(a.manifest.Contributes.Providers) == 0 {
|
||||
return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID)
|
||||
}
|
||||
if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "")
|
||||
}
|
||||
|
||||
func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||||
if a.manifest.Agent == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no agent operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := GenerationRequest{Model: c.Model, Extra: map[string]any{"agent": c.Request}}
|
||||
return buildManifestOperation(*a.manifest.Agent, a.manifest.Auth, request, "")
|
||||
}
|
||||
|
||||
func (a manifestAdapter) ParseAgent(_ context.Context, body []byte) (AgentResult, error) {
|
||||
if a.manifest.AgentResponse == nil {
|
||||
return AgentResult{}, fmt.Errorf("protocol %s has no agent response mapping", a.manifest.Metadata.ID)
|
||||
}
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return AgentResult{}, err
|
||||
}
|
||||
response := a.manifest.AgentResponse
|
||||
result := AgentResult{Text: firstPathValue(payload, response.TextPaths...), Reasoning: firstPathValue(payload, response.ReasoningPaths...)}
|
||||
if response.ToolCallsPath == "" {
|
||||
return result, nil
|
||||
}
|
||||
for index, item := range arrayValue(pathValue(payload, response.ToolCallsPath)) {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
call := AgentToolCall{
|
||||
ID: firstPathValue(object, response.ToolCallIDPaths...),
|
||||
Name: firstPathValue(object, response.ToolCallNamePaths...),
|
||||
Arguments: firstPathJSONValue(object, response.ToolCallArgsPaths...),
|
||||
ThoughtSignature: firstPathValue(object, response.ToolCallSignaturePaths...),
|
||||
}
|
||||
if strings.TrimSpace(call.Name) != "" {
|
||||
if strings.TrimSpace(call.ID) == "" {
|
||||
call.ID = syntheticAgentToolCallID(body, index)
|
||||
}
|
||||
result.ToolCalls = append(result.ToolCalls, call)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func syntheticAgentToolCallID(body []byte, index int) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", body, index)))
|
||||
return fmt.Sprintf("call_%x", sum[:8])
|
||||
}
|
||||
|
||||
func (a manifestAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) {
|
||||
if a.manifest.Response.BinaryPayload {
|
||||
return binaryPayloadCreateResult(a.manifest.Response.ResultKind, body)
|
||||
}
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
return a.parse(payload, PollContext{}), nil
|
||||
}
|
||||
|
||||
// binaryPayloadCreateResult 把同步二进制响应包装为单个媒体结果。MIME 以响应内容探测为准,
|
||||
// 空响应必须失败,不能把空内容伪装成生成成功。
|
||||
func binaryPayloadCreateResult(resultKind string, body []byte) (CreateResult, error) {
|
||||
if len(body) == 0 {
|
||||
return CreateResult{}, fmt.Errorf("binary payload response is empty")
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(body), ";")[0]))
|
||||
reference := MediaReference{DataURL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(body), MIMEType: mimeType}
|
||||
result := &Result{}
|
||||
switch resultKind {
|
||||
case "audio":
|
||||
result.Audios = []MediaReference{reference}
|
||||
case "image":
|
||||
result.Images = []MediaReference{reference}
|
||||
case "video":
|
||||
result.Videos = []MediaReference{reference}
|
||||
default:
|
||||
return CreateResult{}, fmt.Errorf("binary payload response requires resultKind image, video or audio, got %q", resultKind)
|
||||
}
|
||||
return CreateResult{Status: StatusSucceeded, Result: result}, nil
|
||||
}
|
||||
|
||||
func (a manifestAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.Poll == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no poll operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.Poll, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
|
||||
func (a manifestAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) {
|
||||
payload, err := decodeObject(body)
|
||||
if err != nil {
|
||||
return PollResult{}, err
|
||||
}
|
||||
result := a.parse(payload, c)
|
||||
return PollResult{TaskID: result.TaskID, Status: result.Status, Result: result.Result, Message: result.Message}, nil
|
||||
}
|
||||
|
||||
func (a manifestAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.Cancel == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.Cancel, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
|
||||
func (a manifestAdapter) BuildResult(_ context.Context, c PollContext) (RequestSpec, error) {
|
||||
if a.manifest.ResultOperation == nil {
|
||||
return RequestSpec{}, fmt.Errorf("protocol %s has no result operation", a.manifest.Metadata.ID)
|
||||
}
|
||||
request := c.Request
|
||||
request.Model = c.Model
|
||||
return buildManifestOperation(*a.manifest.ResultOperation, a.manifest.Auth, request, c.TaskID)
|
||||
}
|
||||
|
||||
func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResult {
|
||||
response := a.manifest.Response
|
||||
env := map[string]any{"response": payload, "taskId": c.TaskID, "request": manifestRequestValues(c.Request)}
|
||||
id := manifestResponseString(response.TaskID, env)
|
||||
if id == "" {
|
||||
id = firstPathValue(payload, response.TaskIDPaths...)
|
||||
}
|
||||
if id == "" {
|
||||
id = c.TaskID
|
||||
}
|
||||
statusText := manifestResponseString(response.Status, env)
|
||||
if statusText == "" {
|
||||
statusText = firstPathValue(payload, response.StatusPaths...)
|
||||
}
|
||||
status := normalizeStatus(statusText)
|
||||
if status == "" {
|
||||
status = StatusPending
|
||||
}
|
||||
message := manifestResponseString(response.Message, env)
|
||||
if message == "" {
|
||||
message = firstPathValue(payload, response.MessagePaths...)
|
||||
}
|
||||
if manifestError(payload, response.ErrorPaths...) {
|
||||
status = StatusFailed
|
||||
}
|
||||
result := &Result{
|
||||
Text: manifestResponseString(response.Text, env),
|
||||
Reasoning: manifestResponseString(response.Reasoning, env),
|
||||
}
|
||||
if result.Text == "" {
|
||||
result.Text = firstPathValue(payload, response.TextPaths...)
|
||||
}
|
||||
if result.Reasoning == "" {
|
||||
result.Reasoning = firstPathValue(payload, response.ReasoningPaths...)
|
||||
}
|
||||
result.Images = manifestResponseMedia(response.Images, env, "image", response.ResultEphemeral)
|
||||
result.Videos = manifestResponseMedia(response.Videos, env, "video", response.ResultEphemeral)
|
||||
result.Audios = manifestResponseMedia(response.Audios, env, "audio", response.ResultEphemeral)
|
||||
if response.Usage != nil {
|
||||
if value, err := evaluateManifestValue(response.Usage, env); err == nil {
|
||||
result.Usage = manifestObject(value)
|
||||
}
|
||||
}
|
||||
paths := append([]string{}, a.manifest.Response.ResultURLPaths...)
|
||||
paths = append(paths, a.manifest.Response.ResultPaths...)
|
||||
for _, path := range paths {
|
||||
for _, value := range mediaPathValues(payload, path) {
|
||||
item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral}
|
||||
switch a.manifest.Response.ResultKind {
|
||||
case "image":
|
||||
result.Images = append(result.Images, item)
|
||||
case "audio":
|
||||
result.Audios = append(result.Audios, item)
|
||||
default:
|
||||
result.Videos = append(result.Videos, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == StatusPending && (result.Text != "" || len(result.Images) > 0 || len(result.Videos) > 0 || len(result.Audios) > 0) {
|
||||
status = StatusSucceeded
|
||||
}
|
||||
if result.Text == "" && result.Reasoning == "" && len(result.Images) == 0 && len(result.Videos) == 0 && len(result.Audios) == 0 {
|
||||
result = nil
|
||||
}
|
||||
return CreateResult{TaskID: id, Status: status, Result: result, Message: message}
|
||||
}
|
||||
|
||||
func validateManifestRequest(rules []ManifestValidation, request GenerationRequest) error {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
env := map[string]any{"request": manifestRequestValues(request)}
|
||||
for _, rule := range rules {
|
||||
value, err := evaluateManifestValue(rule.Assert, env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("协议参数校验表达式错误:%w", err)
|
||||
}
|
||||
if !manifestTruthy(value) {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(rule.Message))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manifestResponseString(template any, env map[string]any) string {
|
||||
if template == nil {
|
||||
return ""
|
||||
}
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
items := manifestArray(value)
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func manifestResponseMedia(template any, env map[string]any, kind string, ephemeral bool) []MediaReference {
|
||||
if template == nil {
|
||||
return nil
|
||||
}
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return mediaReferencesFromManifestValue(value, kind, ephemeral)
|
||||
}
|
||||
|
||||
func mediaReferencesFromManifestValue(value any, kind string, ephemeral bool) []MediaReference {
|
||||
result := make([]MediaReference, 0)
|
||||
for _, item := range manifestArray(value) {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
reference := MediaReference{Kind: kind, Ephemeral: ephemeral}
|
||||
if strings.HasPrefix(trimmed, "data:") {
|
||||
reference.DataURL = trimmed
|
||||
} else {
|
||||
reference.URL = trimmed
|
||||
}
|
||||
result = append(result, reference)
|
||||
case map[string]any:
|
||||
reference := MediaReference{
|
||||
ID: manifestString(typed["id"]), URL: manifestString(typed["url"]), DataURL: manifestString(typed["dataUrl"]),
|
||||
Kind: defaultValue(manifestString(typed["kind"]), kind), Role: manifestString(typed["role"]), MIMEType: manifestString(typed["mimeType"]),
|
||||
Name: manifestString(typed["name"]), Order: manifestInt(typed["order"]), Weight: manifestFloat(typed["weight"]), Ephemeral: ephemeral || manifestTruthy(typed["ephemeral"]),
|
||||
}
|
||||
if reference.URL == "" {
|
||||
reference.URL = firstString(typed, "file_url", "fileUrl", "image_url", "imageUrl", "video_url", "videoUrl", "audio_url", "audioUrl", "uri")
|
||||
}
|
||||
if reference.DataURL == "" {
|
||||
reference.DataURL = firstString(typed, "data_url", "b64_json")
|
||||
}
|
||||
// OpenAI / Ark 等渠道常直接返回裸 b64_json;声明式结果下载要求 data URL。
|
||||
reference.DataURL = normalizeManifestInlineDataURL(reference.DataURL, reference.Kind, firstString(typed, "output_format", "mime_type", "mimeType"))
|
||||
if reference.URL != "" || reference.DataURL != "" {
|
||||
result = append(result, reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeManifestInlineDataURL(value, kind, formatHint string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "data:") {
|
||||
return value
|
||||
}
|
||||
return "data:" + manifestInlineMediaMIME(kind, formatHint) + ";base64," + value
|
||||
}
|
||||
|
||||
func manifestInlineMediaMIME(kind, formatHint string) string {
|
||||
hint := strings.ToLower(strings.TrimSpace(formatHint))
|
||||
switch hint {
|
||||
case "image/png", "image/jpeg", "image/webp", "image/gif", "audio/mpeg", "audio/wav", "audio/ogg", "video/mp4", "video/webm":
|
||||
return hint
|
||||
case "image/jpg":
|
||||
return "image/jpeg"
|
||||
case "audio/mp3":
|
||||
return "audio/mpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpeg", "jpg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "wav":
|
||||
return "audio/wav"
|
||||
case "mp4":
|
||||
return "video/mp4"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "audio":
|
||||
return "audio/mpeg"
|
||||
case "video":
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
manifestHTMLVideoRegex = regexp.MustCompile(`(?i)<video[^>]+src=['"]([^'"]+)['"]`)
|
||||
)
|
||||
|
||||
func mediaPathValues(payload map[string]any, path string) []string {
|
||||
value := pathValue(payload, path)
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||||
var res []string
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
res = append(res, m[1])
|
||||
}
|
||||
}
|
||||
if len(res) > 0 {
|
||||
return res
|
||||
}
|
||||
}
|
||||
if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||||
var res []string
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
res = append(res, m[1])
|
||||
}
|
||||
}
|
||||
if len(res) > 0 {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
values = append(values, strings.TrimSpace(typed))
|
||||
}
|
||||
case map[string]any:
|
||||
if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" {
|
||||
values = append(values, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
// 声明式插件请求的求值:把清单里的表达式、查询、文件与变换(transform)
|
||||
// 代入任务输入,得到最终请求。所有取值都经过白名单路径与类型规范化。
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildManifestOperation(operation ManifestOperation, auth ManifestAuth, request GenerationRequest, taskID string) (RequestSpec, error) {
|
||||
requestValues := manifestRequestValues(request)
|
||||
env := map[string]any{"request": requestValues, "taskId": taskID}
|
||||
var body any
|
||||
if operation.Body != nil {
|
||||
value, err := evaluateManifestValue(operation.Body, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request body: %w", err)
|
||||
}
|
||||
body = normalizeManifestValue(value)
|
||||
} else if len(operation.Fields) > 0 {
|
||||
legacyBody := make(map[string]any, len(operation.Fields))
|
||||
for key, expression := range operation.Fields {
|
||||
value := manifestExpressionValue(expression, request, taskID)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
setMapPath(legacyBody, key, value)
|
||||
}
|
||||
body = normalizeManifestValue(legacyBody)
|
||||
}
|
||||
pathValue := any(operation.Path)
|
||||
if operation.PathTemplate != nil {
|
||||
pathValue = operation.PathTemplate
|
||||
}
|
||||
evaluatedPath, err := evaluateManifestValue(pathValue, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request path: %w", err)
|
||||
}
|
||||
path := strings.ReplaceAll(manifestString(evaluatedPath), "{{taskId}}", url.PathEscape(taskID))
|
||||
// Model identifiers from async aggregators commonly contain path segments
|
||||
// (for example openai/gpt-image/edit). Escape each segment while preserving
|
||||
// the provider's intentional slash separators in the manifest path.
|
||||
escapedModel := strings.ReplaceAll(url.PathEscape(request.Model), "%2F", "/")
|
||||
path = strings.ReplaceAll(path, "{{model}}", escapedModel)
|
||||
path = interpolateManifestString(path, env)
|
||||
if !isRelativePath(path) {
|
||||
return RequestSpec{}, fmt.Errorf("evaluated request path must be relative: %q", path)
|
||||
}
|
||||
headers, err := evaluateManifestStringMap(operation.Headers, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request headers: %w", err)
|
||||
}
|
||||
query, err := evaluateManifestQuery(operation.Query, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request query: %w", err)
|
||||
}
|
||||
files, err := evaluateManifestFiles(operation.Files, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, err
|
||||
}
|
||||
contentTypeValue := any(operation.ContentType)
|
||||
if operation.ContentTypeTemplate != nil {
|
||||
contentTypeValue = operation.ContentTypeTemplate
|
||||
}
|
||||
evaluatedContentType, err := evaluateManifestValue(contentTypeValue, env)
|
||||
if err != nil {
|
||||
return RequestSpec{}, fmt.Errorf("evaluate request content type: %w", err)
|
||||
}
|
||||
contentType := defaultValue(manifestString(evaluatedContentType), "application/json")
|
||||
return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, OriginPath: operation.OriginPath, ContentType: contentType, Headers: headers, Query: query, Body: body, Files: files, Auth: auth}, nil
|
||||
}
|
||||
|
||||
func evaluateManifestStringMap(values map[string]any, env map[string]any) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for key, template := range values {
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if text := strings.TrimSpace(manifestString(value)); text != "" {
|
||||
result[key] = text
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func evaluateManifestQuery(values map[string]any, env map[string]any) (map[string][]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string][]string, len(values))
|
||||
for key, template := range values {
|
||||
value, err := evaluateManifestValue(template, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range manifestArray(value) {
|
||||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||||
result[key] = append(result[key], text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func evaluateManifestFiles(parts []ManifestFilePart, env map[string]any) ([]RequestFilePart, error) {
|
||||
result := make([]RequestFilePart, 0)
|
||||
for _, part := range parts {
|
||||
source, err := evaluateManifestValue(part.Source, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart file %q: %w", part.Name, err)
|
||||
}
|
||||
filename, err := evaluateManifestValue(part.Filename, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart filename %q: %w", part.Name, err)
|
||||
}
|
||||
mimeType, err := evaluateManifestValue(part.MIMEType, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate multipart MIME type %q: %w", part.Name, err)
|
||||
}
|
||||
for index, value := range manifestArray(source) {
|
||||
references := mediaReferencesFromManifestValue(value, "file", false)
|
||||
for _, reference := range references {
|
||||
name := strings.TrimSpace(manifestString(filename))
|
||||
if name == "" {
|
||||
name = reference.Name
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s-%d", part.Name, index+1)
|
||||
}
|
||||
contentType := strings.TrimSpace(manifestString(mimeType))
|
||||
if contentType == "" {
|
||||
contentType = reference.MIMEType
|
||||
}
|
||||
result = append(result, RequestFilePart{Name: part.Name, Filename: name, MIMEType: contentType, Reference: reference})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeManifestValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if len(v) == 0 {
|
||||
return v
|
||||
}
|
||||
isSequential := true
|
||||
for i := 0; i < len(v); i++ {
|
||||
if _, ok := v[strconv.Itoa(i)]; !ok {
|
||||
isSequential = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isSequential {
|
||||
arr := make([]any, len(v))
|
||||
for i := 0; i < len(v); i++ {
|
||||
arr[i] = normalizeManifestValue(v[strconv.Itoa(i)])
|
||||
}
|
||||
return arr
|
||||
}
|
||||
res := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
res[k] = normalizeManifestValue(val)
|
||||
}
|
||||
return res
|
||||
case []any:
|
||||
arr := make([]any, len(v))
|
||||
for i, val := range v {
|
||||
arr[i] = normalizeManifestValue(val)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any {
|
||||
expression = strings.TrimSpace(expression)
|
||||
source := expression
|
||||
transforms := []string{}
|
||||
if separator := strings.IndexByte(expression, '|'); separator >= 0 {
|
||||
source = strings.TrimSpace(expression[:separator])
|
||||
transforms = strings.Split(expression[separator+1:], "|")
|
||||
}
|
||||
|
||||
var value any
|
||||
switch {
|
||||
case source == "taskId":
|
||||
value = taskID
|
||||
case strings.HasPrefix(source, "request."):
|
||||
value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request."))
|
||||
default:
|
||||
value = source
|
||||
}
|
||||
for _, transform := range transforms {
|
||||
value = applyManifestTransform(value, transform, request)
|
||||
if value == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// manifestRequestValues is deliberately a small, JSON-shaped view of the
|
||||
// platform request. It lets uploaded manifests address media items by index
|
||||
// without exposing Go structs or adding provider-specific host code.
|
||||
func manifestRequestValues(request GenerationRequest) map[string]any {
|
||||
userContent := make([]any, 0)
|
||||
if strings.TrimSpace(request.Prompt) != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt})
|
||||
}
|
||||
for _, img := range request.Images {
|
||||
val := defaultValue(img.URL, img.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
for _, vid := range request.Videos {
|
||||
val := defaultValue(vid.URL, vid.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
for _, aud := range request.Audios {
|
||||
val := defaultValue(aud.URL, aud.DataURL)
|
||||
if val != "" {
|
||||
userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}})
|
||||
}
|
||||
}
|
||||
messages := make([]any, 0, len(request.Messages)+2)
|
||||
// instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。
|
||||
// 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、
|
||||
// Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。
|
||||
if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||||
}
|
||||
for _, message := range request.Messages {
|
||||
if strings.TrimSpace(message.Role) == "" || message.Content == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": message.Role, "content": message.Content})
|
||||
}
|
||||
if len(userContent) > 0 {
|
||||
content := any(userContent)
|
||||
if len(userContent) == 1 && len(request.Images)+len(request.Videos)+len(request.Audios) == 0 {
|
||||
content = request.Prompt
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": "user", "content": content})
|
||||
}
|
||||
|
||||
inputs := request.Inputs
|
||||
if len(inputs) == 0 {
|
||||
inputs = append(inputs, request.Images...)
|
||||
inputs = append(inputs, request.Videos...)
|
||||
inputs = append(inputs, request.Audios...)
|
||||
}
|
||||
output := request.Output
|
||||
if output.Count == 0 {
|
||||
output.Count = request.ImageCount
|
||||
}
|
||||
if output.Duration == 0 {
|
||||
output.Duration = request.Duration
|
||||
}
|
||||
if output.AspectRatio == "" {
|
||||
output.AspectRatio = request.AspectRatio
|
||||
}
|
||||
if output.Resolution == "" {
|
||||
output.Resolution = request.Resolution
|
||||
}
|
||||
if output.Quality == "" {
|
||||
output.Quality = request.Quality
|
||||
}
|
||||
output.GenerateAudio = output.GenerateAudio || request.GenerateAudio
|
||||
output.Watermark = output.Watermark || request.Watermark
|
||||
outputValue, _ := requestAsManifestValue(output)
|
||||
providerOptionsValue, _ := requestAsManifestValue(request.ProviderOptions)
|
||||
if providerOptionsValue == nil {
|
||||
providerOptionsValue = map[string]any{}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"capability": request.Capability,
|
||||
"model": request.Model,
|
||||
"prompt": request.Prompt,
|
||||
"instructions": request.Instructions,
|
||||
"messages": messages,
|
||||
"inputs": manifestMediaValues(inputs),
|
||||
"images": manifestMediaValues(request.Images),
|
||||
"videos": manifestMediaValues(request.Videos),
|
||||
"audios": manifestMediaValues(request.Audios),
|
||||
"imageCount": request.ImageCount,
|
||||
"duration": request.Duration,
|
||||
"aspectRatio": request.AspectRatio,
|
||||
"resolution": request.Resolution,
|
||||
"quality": request.Quality,
|
||||
"generateAudio": request.GenerateAudio,
|
||||
"watermark": request.Watermark,
|
||||
"operation": request.Operation,
|
||||
"output": outputValue,
|
||||
"providerOptions": providerOptionsValue,
|
||||
"extra": request.Extra,
|
||||
}
|
||||
}
|
||||
|
||||
func hasSystemMessage(messages []Message) bool {
|
||||
for _, message := range messages {
|
||||
if strings.EqualFold(strings.TrimSpace(message.Role), "system") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requestAsManifestValue(value any) (any, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result any
|
||||
if err := json.Unmarshal(encoded, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func manifestModelID(request GenerationRequest) string {
|
||||
modelID := strings.TrimSpace(request.Model)
|
||||
if separator := strings.LastIndex(modelID, "::"); separator >= 0 {
|
||||
modelID = modelID[separator+2:]
|
||||
}
|
||||
return strings.ToLower(modelID)
|
||||
}
|
||||
|
||||
func manifestMediaValues(values []MediaReference) []any {
|
||||
result := make([]any, 0, len(values))
|
||||
for index, value := range values {
|
||||
order := value.Order
|
||||
if order == 0 && index > 0 {
|
||||
order = index
|
||||
}
|
||||
resolved := defaultValue(value.URL, value.DataURL)
|
||||
result = append(result, map[string]any{
|
||||
"id": value.ID,
|
||||
"url": value.URL,
|
||||
"dataUrl": value.DataURL,
|
||||
"value": resolved,
|
||||
"kind": value.Kind,
|
||||
"role": value.Role,
|
||||
"mimeType": value.MIMEType,
|
||||
"name": value.Name,
|
||||
"order": order,
|
||||
"weight": value.Weight,
|
||||
"metadata": value.Metadata,
|
||||
"source": map[string]any{
|
||||
"type": manifestMediaSourceType(value),
|
||||
"value": resolved,
|
||||
"mimeType": value.MIMEType,
|
||||
},
|
||||
"ephemeral": value.Ephemeral,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func manifestMediaSourceType(value MediaReference) string {
|
||||
if value.DataURL != "" {
|
||||
return "data"
|
||||
}
|
||||
if value.URL != "" {
|
||||
return "url"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func applyManifestTransform(value any, transform string, request GenerationRequest) any {
|
||||
transform = strings.ToLower(strings.TrimSpace(transform))
|
||||
if transform == "bool" || transform == "boolean" {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
return s == "true" || s == "1" || s == "yes"
|
||||
case int, int64, float64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if transform == "int" || transform == "integer" {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if transform == "omit_zero" {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case int64:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case float64:
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanModel := manifestModelID(request)
|
||||
if strings.HasPrefix(transform, "omit_unless_model_contains:") {
|
||||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:")))
|
||||
if needle == "" || !strings.Contains(cleanModel, needle) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_if_model_contains:") {
|
||||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:")))
|
||||
if needle != "" && strings.Contains(cleanModel, needle) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_unless_model_equals:") {
|
||||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:")))
|
||||
if target == "" || cleanModel != target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(transform, "omit_if_model_equals:") {
|
||||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:")))
|
||||
if target != "" && cleanModel == target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") {
|
||||
return nil
|
||||
}
|
||||
if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) {
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
switch transform {
|
||||
case "trim":
|
||||
return strings.TrimSpace(text)
|
||||
case "lower", "lowercase":
|
||||
return strings.ToLower(text)
|
||||
case "upper", "uppercase":
|
||||
return strings.ToUpper(text)
|
||||
case "resolution_p":
|
||||
if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) {
|
||||
return strings.TrimSpace(text) + "p"
|
||||
}
|
||||
return text
|
||||
case "video-resolution", "video_resolution", "videoresolution":
|
||||
// Compatibility for already-installed 1.0.1 manifests. New plugins
|
||||
// should declare exact enum values and use generic string transforms.
|
||||
return normalizeLegacyManifestVideoResolution(text)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyManifestVideoResolution(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
for _, char := range normalized {
|
||||
if char < '0' || char > '9' {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return normalized + "p"
|
||||
}
|
||||
|
||||
func pathValue(payload map[string]any, path string) any {
|
||||
return manifestPathValue(payload, path)
|
||||
}
|
||||
|
||||
func arrayValue(value any) []any {
|
||||
items, _ := value.([]any)
|
||||
return items
|
||||
}
|
||||
|
||||
func firstPathValue(payload map[string]any, paths ...string) string {
|
||||
for _, path := range paths {
|
||||
value := pathValue(payload, path)
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -28,9 +28,49 @@ type PluginPackage struct {
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// PluginPackageInfo 是只读取清单、不解压其余文件的包摘要。
|
||||
// 它刻意不携带 Files:调用方无法误把"只校验过结构"的包内容写到磁盘或执行。
|
||||
type PluginPackageInfo struct {
|
||||
Manifest Manifest
|
||||
ManifestRaw []byte
|
||||
}
|
||||
|
||||
// ParsePluginPackage validates the package container and returns its manifest
|
||||
// and files. Uploaded code is never executed by this function.
|
||||
func ParsePluginPackage(data []byte) (PluginPackage, error) {
|
||||
return readPluginPackage(data, nil)
|
||||
}
|
||||
|
||||
// InspectPluginPackage 与 ParsePluginPackage 做同样的结构校验(路径白名单、文件数、
|
||||
// 单文件声明大小、符号链接、manifest 与运行时入口),但只解压 manifest.json。
|
||||
//
|
||||
// 为什么需要它:官方支付插件每个包内含 7 个平台的可执行文件,解压后约 44MB;
|
||||
// 服务启动(以及每个构造 Service 的测试)只需要清单即可完成注册。全量解压让每次
|
||||
// 启动多花约 4 秒,CI 中上百个测试累计 7 分钟以上。
|
||||
//
|
||||
// 限制:未解压的条目只校验 zip 头声明的大小,不校验 CRC。整包完整性由调用方
|
||||
// 对原始字节计算 SHA-256 保证;真正需要落盘执行文件时仍必须走 ParsePluginPackage。
|
||||
func InspectPluginPackage(data []byte) (PluginPackageInfo, error) {
|
||||
pkg, err := readPluginPackage(data, func(string) bool { return false })
|
||||
if err != nil {
|
||||
return PluginPackageInfo{}, err
|
||||
}
|
||||
return PluginPackageInfo{Manifest: pkg.Manifest, ManifestRaw: pkg.ManifestRaw}, nil
|
||||
}
|
||||
|
||||
// ParsePluginPackageFiles 与 ParsePluginPackage 做完全相同的校验,但只解压 keep 返回 true
|
||||
// 的条目(manifest.json 总会解压);其余条目在 Files 中值为 nil,仅用于存在性判断。
|
||||
// 被解压的条目仍会经过 zip 的 CRC 校验。
|
||||
func ParsePluginPackageFiles(data []byte, keep func(name string) bool) (PluginPackage, error) {
|
||||
if keep == nil {
|
||||
keep = func(string) bool { return false }
|
||||
}
|
||||
return readPluginPackage(data, keep)
|
||||
}
|
||||
|
||||
// readPluginPackage 是各种读取方式的共同实现。keep 为 nil 表示解压全部条目;
|
||||
// 否则只解压 manifest.json 与 keep 选中的条目,其余只登记文件名(值为 nil)。
|
||||
func readPluginPackage(data []byte, keep func(name string) bool) (PluginPackage, error) {
|
||||
if len(data) == 0 || len(data) > PluginPackageMaxBytes {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package must be between 1 and %d bytes", PluginPackageMaxBytes)
|
||||
}
|
||||
@@ -60,6 +100,10 @@ func ParsePluginPackage(data []byte) (PluginPackage, error) {
|
||||
if file.UncompressedSize64 > pluginPackageMaxEntry {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry)
|
||||
}
|
||||
if keep != nil && name != "manifest.json" && !keep(name) {
|
||||
files[name] = nil
|
||||
continue
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return PluginPackage{}, fmt.Errorf("open plugin package file %q: %w", name, err)
|
||||
|
||||
@@ -69,6 +69,57 @@ func TestParsePluginPackageAcceptsTaggedRPCBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// InspectPluginPackage 只解压清单:结构校验与 ParsePluginPackage 相同(路径白名单、运行时入口),
|
||||
// 但返回值不携带文件内容,调用方无法把「只校验过结构」的可执行文件写盘。
|
||||
func TestInspectPluginPackageValidatesStructureWithoutContents(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"tagged-payment",
|
||||
"name":"Tagged Payment",
|
||||
"version":"1.0.0",
|
||||
"author":"Test",
|
||||
"enabled":true,
|
||||
"runtime":{"backend":"rpc","backendEntry":"backend/provider"},
|
||||
"contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}
|
||||
}`)
|
||||
info, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "backend/provider-linux-amd64": []byte("binary")}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Manifest.Metadata.ID != "tagged-payment" || len(info.ManifestRaw) == 0 {
|
||||
t.Fatalf("info = %#v", info)
|
||||
}
|
||||
if _, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest})); err == nil {
|
||||
t.Fatal("rpc package without any backend artifact was accepted by inspection")
|
||||
}
|
||||
if _, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "backend/provider": []byte("x"), "server/evil": []byte("x")})); err == nil {
|
||||
t.Fatal("forbidden package path was accepted by inspection")
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePluginPackageFiles 只解压选中的条目;未选中的条目以 nil 登记,便于存在性判断。
|
||||
func TestParsePluginPackageFilesLoadsOnlySelectedEntries(t *testing.T) {
|
||||
manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"tagged-payment","name":"Tagged Payment","version":"1.0.0","author":"Test","enabled":true,"runtime":{"backend":"rpc","backendEntry":"backend/provider"},"contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`)
|
||||
data := zipPluginPackage(t, map[string][]byte{
|
||||
"manifest.json": manifest,
|
||||
"backend/provider-linux-amd64": []byte("linux"),
|
||||
"backend/provider-darwin-arm64": []byte("darwin"),
|
||||
})
|
||||
pkg, err := ParsePluginPackageFiles(data, func(name string) bool { return name == "backend/provider-linux-amd64" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(pkg.Files["backend/provider-linux-amd64"]) != "linux" {
|
||||
t.Fatalf("selected entry content = %q", pkg.Files["backend/provider-linux-amd64"])
|
||||
}
|
||||
if content, listed := pkg.Files["backend/provider-darwin-arm64"]; !listed || content != nil {
|
||||
t.Fatalf("unselected entry must be listed with nil content, got listed=%v content=%q", listed, content)
|
||||
}
|
||||
if len(pkg.ManifestRaw) == 0 {
|
||||
t.Fatal("manifest must always be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func zipPluginPackage(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
|
||||
@@ -24,6 +24,12 @@ func PaymentRPCBackendCandidates(entry, goos, goarch string) []string {
|
||||
return uniqueStrings(candidates)
|
||||
}
|
||||
|
||||
// IsPaymentRPCBackendArtifact 判断包内文件是否是 entry 对应的某个平台的可执行文件
|
||||
// (entry 本身、entry-<os>-<arch>、<dir>/<os>-<arch>/<base>,以及它们的 .exe 形式)。
|
||||
func IsPaymentRPCBackendArtifact(entry, name string) bool {
|
||||
return HasAnyPaymentRPCBackend(entry, map[string][]byte{name: nil})
|
||||
}
|
||||
|
||||
func HasAnyPaymentRPCBackend(entry string, files map[string][]byte) bool {
|
||||
entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe")
|
||||
if entry == "" {
|
||||
|
||||
Reference in new issue
Block a user