From a6303d0ba97b34365588ec157a062edf3ab9fa6b Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 4 Jul 2026 12:22:31 +0800 Subject: [PATCH 1/3] v1.0.4-preview.1 --- relay/channel/task/doubao/adaptor.go | 172 ++++++- .../task/doubao/adaptor_billing_test.go | 211 +++++++++ .../task/taskcommon/generic_billing.go | 433 ++++++++++++++++++ .../task/taskcommon/generic_billing_test.go | 237 ++++++++++ .../task/taskcommon/path_config_test.go | 37 ++ relay/channel/task/taskcommon/request_body.go | 37 +- relay/common/relay_info.go | 53 ++- relay/relay_task.go | 37 +- relay/relay_task_test.go | 85 ++++ 9 files changed, 1262 insertions(+), 40 deletions(-) create mode 100644 relay/channel/task/taskcommon/generic_billing.go create mode 100644 relay/channel/task/taskcommon/generic_billing_test.go diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 0aa9185..ab19047 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -138,11 +138,24 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom return nil } - // Accept only POST /v1/video/generations as "generate" action. - taskErr = relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) - if taskErr != nil { - return taskErr + if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } + + var req relaycommon.TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if strings.TrimSpace(req.Model) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest) + } + if strings.TrimSpace(req.Prompt) == "" && !hasUsableOfficialContent(req.Content) { + return service.TaskErrorWrapperLocal(fmt.Errorf("prompt or content is required"), "invalid_request", http.StatusBadRequest) + } + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + req.Images = []string{req.Image} + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) return nil } @@ -210,6 +223,34 @@ func hasVideoInContent(content []ContentItem) bool { return false } +func hasUsableOfficialContent(content []map[string]any) bool { + items, err := topLevelContentItems(content) + if err != nil { + return false + } + for _, item := range items { + switch item.Type { + case "text": + if strings.TrimSpace(item.Text) != "" { + return true + } + case "image_url": + if item.ImageURL != nil && strings.TrimSpace(item.ImageURL.URL) != "" { + return true + } + case "video_url": + if item.VideoURL != nil && strings.TrimSpace(item.VideoURL.URL) != "" { + return true + } + case "audio_url": + if item.AudioURL != nil && strings.TrimSpace(item.AudioURL.URL) != "" { + return true + } + } + } + return false +} + // hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目, // 避免构建完整的上游 requestPayload。 func hasVideoInMetadata(metadata map[string]interface{}) bool { @@ -382,6 +423,9 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* }) } } + if err := applyTopLevelSeedanceOptions(req, &r); err != nil { + return nil, err + } metadata := req.Metadata if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil { @@ -392,15 +436,125 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* r.Duration = lo.ToPtr(dto.IntValue(sec)) } - r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) - r.Content = append(r.Content, ContentItem{ - Type: "text", - Text: req.Prompt, - }) + if strings.TrimSpace(req.Prompt) != "" { + r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } return &r, nil } +func applyTopLevelSeedanceOptions(req *relaycommon.TaskSubmitReq, r *requestPayload) error { + if req == nil || r == nil { + return nil + } + if len(req.Content) > 0 { + items, err := topLevelContentItems(req.Content) + if err != nil { + return err + } + r.Content = items + } + if req.CallbackURL != "" { + r.CallbackURL = req.CallbackURL + } + if req.ReturnLastFrame != nil { + r.ReturnLastFrame = lo.ToPtr(dto.BoolValue(*req.ReturnLastFrame)) + } + if req.ServiceTier != "" { + r.ServiceTier = req.ServiceTier + } + if req.ExecutionExpiresAfter != nil { + r.ExecutionExpiresAfter = lo.ToPtr(dto.IntValue(*req.ExecutionExpiresAfter)) + } + if req.Resolution != "" { + r.Resolution = lo.ToPtr(req.Resolution) + } + if ratio := firstNonEmptyString(req.Ratio, req.AspectRatio, req.Size); ratio != "" { + r.Ratio = lo.ToPtr(ratio) + } + if req.Duration > 0 { + r.Duration = lo.ToPtr(dto.IntValue(req.Duration)) + } + if req.DurationSeconds != nil { + r.Duration = lo.ToPtr(dto.IntValue(*req.DurationSeconds)) + } + if req.GenerateAudio != nil { + r.GenerateAudio = lo.ToPtr(dto.BoolValue(*req.GenerateAudio)) + } else if req.WithAudio != nil { + r.GenerateAudio = lo.ToPtr(dto.BoolValue(*req.WithAudio)) + } + if req.Draft != nil { + r.Draft = lo.ToPtr(dto.BoolValue(*req.Draft)) + } + if len(req.Tools) > 0 { + tools, err := topLevelTools(req.Tools) + if err != nil { + return err + } + r.Tools = tools + } + if req.SafetyIdentifier != "" { + r.SafetyIdentifier = lo.ToPtr(req.SafetyIdentifier) + } + if req.Priority != nil { + r.Priority = lo.ToPtr(dto.IntValue(*req.Priority)) + } + if req.Frames != nil { + r.Frames = lo.ToPtr(dto.IntValue(*req.Frames)) + } + if req.Seed != nil { + r.Seed = lo.ToPtr(dto.IntValue(*req.Seed)) + } + if req.CameraFixed != nil { + r.CameraFixed = lo.ToPtr(dto.BoolValue(*req.CameraFixed)) + } + if req.Watermark != nil { + r.Watermark = lo.ToPtr(dto.BoolValue(*req.Watermark)) + } + return nil +} + +func topLevelContentItems(raw []map[string]any) ([]ContentItem, error) { + data, err := common.Marshal(raw) + if err != nil { + return nil, errors.Wrap(err, "marshal top-level content failed") + } + var items []ContentItem + if err := common.Unmarshal(data, &items); err != nil { + return nil, errors.Wrap(err, "unmarshal top-level content failed") + } + return items, nil +} + +func topLevelTools(raw []map[string]any) ([]struct { + Type string `json:"type,omitempty"` +}, error) { + data, err := common.Marshal(raw) + if err != nil { + return nil, errors.Wrap(err, "marshal top-level tools failed") + } + var tools []struct { + Type string `json:"type,omitempty"` + } + if err := common.Unmarshal(data, &tools); err != nil { + return nil, errors.Wrap(err, "unmarshal top-level tools failed") + } + return tools, nil +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { if looksLikeSeedanceMediaTask(respBody) { if taskResult, ok, err := taskcommon.ParseConfiguredTaskResult(respBody, dto.ChannelOtherSettings{ diff --git a/relay/channel/task/doubao/adaptor_billing_test.go b/relay/channel/task/doubao/adaptor_billing_test.go index 4c5c1ef..fd2ecdb 100644 --- a/relay/channel/task/doubao/adaptor_billing_test.go +++ b/relay/channel/task/doubao/adaptor_billing_test.go @@ -11,12 +11,41 @@ import ( "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func withDoubaoTaskRateCards(t *testing.T, cards map[string]task_billing_setting.RateCard) { + t.Helper() + original := task_billing_setting.GetRateCardsCopy() + originalData, err := common.Marshal(original) + require.NoError(t, err) + data, err := common.Marshal(cards) + require.NoError(t, err) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(data), + })) + t.Cleanup(func() { + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(originalData), + })) + }) +} + +func withTestQuotaPerUnit(t *testing.T, value float64) { + t.Helper() + original := common.QuotaPerUnit + common.QuotaPerUnit = value + t.Cleanup(func() { + common.QuotaPerUnit = original + }) +} + func TestGetVideoInputRatioUsesResolutionAndVideoInput(t *testing.T) { ratio, ok := GetVideoInputRatio("doubao-seedance-2-0-260128", "", false) require.True(t, ok) @@ -196,6 +225,74 @@ func TestEstimateBillingUsesLegacyRequestPayloadResolution(t *testing.T) { require.Nil(t, ratios) } +func TestEstimateTaskBillingUsesDoubaoRateCard(t *testing.T) { + withTestQuotaPerUnit(t, 1000) + withDoubaoTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "doubao-seedance-1-5-pro-251215": { + Vendor: "doubao", + Unit: "call", + DefaultQuantity: 1, + Strict: true, + Defaults: map[string]string{ + "capability": "video_generation", + "duration": "5", + "generate_audio": "true", + "has_video_input": "false", + "ratio": "adaptive", + "resolution": "720p", + }, + Rows: []task_billing_setting.RateCardRow{ + { + ID: "5s_720p_no_video_input", + Match: map[string]string{ + "duration": "5", + "has_video_input": "false", + "resolution": "720p", + }, + UnitPrice: 0.4, + }, + }, + }, + }) + + duration := 5 + withAudio := false + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-1-5-pro-251215", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "doubao-seedance-1-5-pro-251215", + }, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-1-5-pro-251215", + Prompt: "test", + DurationSeconds: &duration, + Resolution: "720p", + WithAudio: &withAudio, + Capability: "video_generation", + }) + + got, err := taskcommon.EstimateGenericTaskBilling(c, info, ChannelName) + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "doubao-seedance-1-5-pro-251215", got.RuleKey) + assert.Equal(t, "5s_720p_no_video_input", got.RowID) + assert.Equal(t, "call", got.Unit) + assert.InDelta(t, 1.0, got.Quantity, 1e-9) + assert.InDelta(t, 0.4, got.TotalPrice, 1e-9) + assert.Equal(t, 400, got.Quota) + assert.Equal(t, "5", got.Fields["duration"]) + assert.Equal(t, "720p", got.Fields["resolution"]) + assert.Equal(t, "false", got.Fields["has_video_input"]) + assert.Equal(t, "false", got.Fields["generate_audio"]) + assert.Equal(t, "false", got.Fields["has_audio"]) +} + func TestConvertToRequestPayloadPreservesDoubaoVideoFields(t *testing.T) { var req relaycommon.TaskSubmitReq err := common.Unmarshal([]byte(`{ @@ -226,6 +323,91 @@ func TestConvertToRequestPayloadPreservesDoubaoVideoFields(t *testing.T) { assert.Equal(t, "4k", *payload.Resolution) } +func TestConvertToRequestPayloadUsesTopLevelSeedanceFields(t *testing.T) { + var req relaycommon.TaskSubmitReq + err := common.Unmarshal([]byte(`{ + "model": "doubao-seedance-1-5-pro-251215", + "content": [ + { "type": "text", "text": "official prompt" }, + { "type": "image_url", "role": "first_frame", "image_url": { "url": "https://example.com/first.png" } } + ], + "callback_url": "https://example.com/callback", + "return_last_frame": false, + "service_tier": "default", + "execution_expires_after": 3600, + "ratio": "16:9", + "duration": 5, + "resolution": "720p", + "generate_audio": false, + "draft": false, + "tools": [{ "type": "web_search" }], + "safety_identifier": "user-123", + "priority": 0, + "frames": 29, + "seed": 0, + "camera_fixed": false, + "watermark": false + }`), &req) + require.NoError(t, err) + + payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) + require.NoError(t, err) + require.NotNil(t, payload) + require.Len(t, payload.Content, 2) + assert.Equal(t, "text", payload.Content[0].Type) + assert.Equal(t, "official prompt", payload.Content[0].Text) + assert.Equal(t, "image_url", payload.Content[1].Type) + require.NotNil(t, payload.Content[1].ImageURL) + assert.Equal(t, "https://example.com/first.png", payload.Content[1].ImageURL.URL) + assert.Equal(t, "first_frame", payload.Content[1].Role) + assert.Equal(t, "https://example.com/callback", payload.CallbackURL) + require.NotNil(t, payload.ReturnLastFrame) + assert.False(t, bool(*payload.ReturnLastFrame)) + assert.Equal(t, "default", payload.ServiceTier) + require.NotNil(t, payload.ExecutionExpiresAfter) + assert.Equal(t, 3600, int(*payload.ExecutionExpiresAfter)) + require.NotNil(t, payload.Ratio) + assert.Equal(t, "16:9", *payload.Ratio) + require.NotNil(t, payload.Duration) + assert.Equal(t, 5, int(*payload.Duration)) + require.NotNil(t, payload.Resolution) + assert.Equal(t, "720p", *payload.Resolution) + require.NotNil(t, payload.GenerateAudio) + assert.False(t, bool(*payload.GenerateAudio)) + require.NotNil(t, payload.Draft) + assert.False(t, bool(*payload.Draft)) + require.Len(t, payload.Tools, 1) + assert.Equal(t, "web_search", payload.Tools[0].Type) + require.NotNil(t, payload.SafetyIdentifier) + assert.Equal(t, "user-123", *payload.SafetyIdentifier) + require.NotNil(t, payload.Priority) + assert.Equal(t, 0, int(*payload.Priority)) + require.NotNil(t, payload.Frames) + assert.Equal(t, 29, int(*payload.Frames)) + require.NotNil(t, payload.Seed) + assert.Equal(t, 0, int(*payload.Seed)) + require.NotNil(t, payload.CameraFixed) + assert.False(t, bool(*payload.CameraFixed)) + require.NotNil(t, payload.Watermark) + assert.False(t, bool(*payload.Watermark)) + + data, err := common.Marshal(payload) + require.NoError(t, err) + assert.Contains(t, string(data), `"content":[`) + assert.Contains(t, string(data), `"callback_url":"https://example.com/callback"`) + assert.Contains(t, string(data), `"return_last_frame":false`) + assert.Contains(t, string(data), `"execution_expires_after":3600`) + assert.Contains(t, string(data), `"ratio":"16:9"`) + assert.Contains(t, string(data), `"duration":5`) + assert.Contains(t, string(data), `"resolution":"720p"`) + assert.Contains(t, string(data), `"generate_audio":false`) + assert.Contains(t, string(data), `"draft":false`) + assert.Contains(t, string(data), `"priority":0`) + assert.Contains(t, string(data), `"seed":0`) + assert.Contains(t, string(data), `"camera_fixed":false`) + assert.Contains(t, string(data), `"watermark":false`) +} + func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing.T) { var req relaycommon.TaskSubmitReq err := common.Unmarshal([]byte(`{ @@ -281,6 +463,35 @@ func TestValidateConfiguredTaskProtocolAllowsPromptlessMediaRequest(t *testing.T require.Equal(t, []string{"https://example.com/frame.png"}, req.Images) } +func TestValidateRequestAllowsOfficialContentWithoutTopLevelPrompt(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model": "doubao-seedance-2-0-260128", + "content": [ + { "type": "text", "text": "official prompt" } + ], + "ratio": "16:9", + "generate_audio": false + }`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + } + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + + require.Nil(t, taskErr) + require.Equal(t, "generate", info.Action) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + require.Len(t, req.Content, 1) + assert.Equal(t, "official prompt", req.Content[0]["text"]) + require.NotNil(t, req.GenerateAudio) + assert.False(t, *req.GenerateAudio) +} + func TestParseSeedanceMediaTaskResultByShape(t *testing.T) { taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ "object": "media.task", diff --git a/relay/channel/task/taskcommon/generic_billing.go b/relay/channel/task/taskcommon/generic_billing.go new file mode 100644 index 0000000..9b1be86 --- /dev/null +++ b/relay/channel/task/taskcommon/generic_billing.go @@ -0,0 +1,433 @@ +package taskcommon + +import ( + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +func EstimateGenericTaskBilling(c *gin.Context, info *relaycommon.RelayInfo, platform string) (*types.TaskBillingResult, error) { + req := relaycommon.TaskSubmitReq{} + if parsed, err := relaycommon.GetTaskRequest(c); err == nil { + req = parsed + } + raw := genericBillingRawBody(c) + input := types.TaskBillingInput{ + Model: genericBillingOriginModel(info, req), + UpstreamModel: genericBillingUpstreamModel(info, req, raw), + Action: genericBillingAction(info), + Platform: platform, + } + if !task_billing_setting.HasRateCard(input.Model, input.UpstreamModel) { + return nil, nil + } + + fields := genericBillingFields(req, raw) + for key, value := range fields { + input.SetField(key, value) + } + if duration := genericBillingDuration(req, raw, fields); duration > 0 { + input.SetNumber("duration", duration) + input.SetField("duration", formatBillingNumber(duration)) + } + + groupRatio := 1.0 + if info != nil { + groupRatio = info.PriceData.GroupRatioInfo.GroupRatio + } + return task_billing_setting.Calculate(input, groupRatio) +} + +func genericBillingRawBody(c *gin.Context) map[string]any { + raw := map[string]any{} + if finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c); ok && len(strings.TrimSpace(string(finalBody))) > 0 { + _ = common.Unmarshal(finalBody, &raw) + } + return raw +} + +func genericBillingOriginModel(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) string { + if info != nil && strings.TrimSpace(info.OriginModelName) != "" { + return info.OriginModelName + } + return req.Model +} + +func genericBillingUpstreamModel(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq, raw map[string]any) string { + if model := firstBillingString(raw, "model", "model_name"); model != "" { + return model + } + if info != nil && info.ChannelMeta != nil && strings.TrimSpace(info.UpstreamModelName) != "" { + return info.UpstreamModelName + } + return req.Model +} + +func genericBillingAction(info *relaycommon.RelayInfo) string { + if info != nil && info.TaskRelayInfo != nil { + return info.Action + } + return "" +} + +func genericBillingFields(req relaycommon.TaskSubmitReq, raw map[string]any) map[string]string { + fields := map[string]string{} + setIfPresent(fields, "quality", firstBillingString(raw, "quality", "mode"), req.Mode) + if params, ok := billingMap(raw, "parameters"); ok { + setIfPresent(fields, "quality", firstBillingString(params, "quality", "mode"), "") + } + setIfPresent(fields, "mode", firstBillingString(raw, "mode"), req.Mode) + setIfPresent(fields, "capability", firstBillingString(raw, "capability"), req.Capability) + setIfPresent(fields, "control_mode", firstBillingString(raw, "control_mode"), req.ControlMode) + setIfPresent(fields, "input_mode", firstBillingString(raw, "input_mode"), req.InputMode) + setIfPresent(fields, "resolution", firstBillingString(raw, "resolution"), req.Resolution, firstBillingString(req.Metadata, "resolution")) + if params, ok := billingMap(raw, "parameters"); ok { + setIfPresent(fields, "resolution", firstBillingString(params, "resolution"), "") + } + setIfPresent(fields, "ratio", firstBillingString(raw, "ratio", "aspect_ratio", "aspectRatio", "size"), req.Ratio, req.AspectRatio, req.Size, firstBillingString(req.Metadata, "ratio", "aspect_ratio", "aspectRatio", "size")) + if params, ok := billingMap(raw, "parameters"); ok { + setIfPresent(fields, "ratio", firstBillingString(params, "ratio", "aspect_ratio", "aspectRatio", "size"), "") + } + if ratio := fields["ratio"]; ratio != "" { + fields["aspect_ratio"] = ratio + } + if audio, ok := genericBillingAudio(req, raw); ok { + value := strconv.FormatBool(audio) + fields["generate_audio"] = value + fields["with_audio"] = value + fields["has_audio"] = value + } + fields["has_video_input"] = strconv.FormatBool(genericBillingHasVideoInput(req, raw)) + setCountField(fields, "image_count", genericBillingMediaCount(req, raw, "image", "image_url", "images", "image_list")) + setCountField(fields, "video_count", genericBillingMediaCount(req, raw, "video", "video_url", "videos", "video_list")) + return fields +} + +func genericBillingDuration(req relaycommon.TaskSubmitReq, raw map[string]any, fields map[string]string) float64 { + if duration := positiveBillingNumber(raw, "duration", "duration_seconds", "durationSeconds", "seconds"); duration > 0 { + return duration + } + if params, ok := billingMap(raw, "parameters"); ok { + if duration := positiveBillingNumber(params, "duration", "duration_seconds", "durationSeconds", "seconds"); duration > 0 { + return duration + } + } + if req.DurationSeconds != nil && *req.DurationSeconds > 0 { + return float64(*req.DurationSeconds) + } + if req.Duration > 0 { + return float64(req.Duration) + } + if seconds := positiveBillingNumber(map[string]any{"seconds": req.Seconds}, "seconds"); seconds > 0 { + return seconds + } + if duration := positiveBillingNumber(req.Metadata, "duration", "duration_seconds", "durationSeconds", "seconds"); duration > 0 { + return duration + } + if rawDuration := fields["duration"]; rawDuration != "" { + if duration, err := strconv.ParseFloat(rawDuration, 64); err == nil && duration > 0 { + return duration + } + } + return 0 +} + +func genericBillingAudio(req relaycommon.TaskSubmitReq, raw map[string]any) (bool, bool) { + if value, ok := billingBool(raw, "generate_audio", "with_audio", "audio", "has_audio", "sound"); ok { + return value, true + } + if params, ok := billingMap(raw, "parameters"); ok { + if value, ok := billingBool(params, "generate_audio", "with_audio", "audio", "has_audio", "sound"); ok { + return value, true + } + } + if req.GenerateAudio != nil { + return *req.GenerateAudio, true + } + if req.WithAudio != nil { + return *req.WithAudio, true + } + return billingBool(req.Metadata, "generate_audio", "with_audio", "audio", "has_audio", "sound") +} + +func genericBillingHasVideoInput(req relaycommon.TaskSubmitReq, raw map[string]any) bool { + if hasVideoInput(raw) { + return true + } + if params, ok := billingMap(raw, "parameters"); ok && hasVideoInput(params) { + return true + } + if len(raw) > 0 { + return false + } + if hasVideoInput(req.Metadata) { + return true + } + return false +} + +func hasVideoInput(raw map[string]any) bool { + if raw == nil { + return false + } + for _, key := range []string{"video_url", "video", "videos", "video_list", "input_video", "input_videos"} { + if value, ok := raw[key]; ok && usableBillingMedia(value) { + return true + } + } + if content, ok := raw["content"]; ok && contentHasVideo(content) { + return true + } + if input, ok := billingMap(raw, "input"); ok { + if hasVideoInput(input) { + return true + } + } + return false +} + +func contentHasVideo(value any) bool { + items, ok := value.([]any) + if !ok { + return false + } + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + if strings.EqualFold(firstBillingString(itemMap, "type"), "video_url") { + return usableBillingMedia(itemMap["video_url"]) || usableBillingMedia(itemMap["video"]) || usableBillingMedia(itemMap["url"]) + } + if usableBillingMedia(itemMap["video_url"]) || usableBillingMedia(itemMap["video"]) { + return true + } + } + return false +} + +func genericBillingMediaCount(req relaycommon.TaskSubmitReq, raw map[string]any, singleKey, urlKey, listKey, altListKey string) int { + count := mediaCount(raw[singleKey]) + mediaCount(raw[urlKey]) + mediaCount(raw[listKey]) + mediaCount(raw[altListKey]) + count += contentMediaCount(raw["content"], urlKey) + if input, ok := billingMap(raw, "input"); ok { + count += mediaCount(input[singleKey]) + mediaCount(input[urlKey]) + mediaCount(input[listKey]) + mediaCount(input[altListKey]) + count += contentMediaCount(input["content"], urlKey) + } + if params, ok := billingMap(raw, "parameters"); ok { + count += mediaCount(params[singleKey]) + mediaCount(params[urlKey]) + mediaCount(params[listKey]) + mediaCount(params[altListKey]) + count += contentMediaCount(params["content"], urlKey) + } + if len(raw) == 0 && req.Metadata != nil { + count += mediaCount(req.Metadata[singleKey]) + mediaCount(req.Metadata[urlKey]) + mediaCount(req.Metadata[listKey]) + mediaCount(req.Metadata[altListKey]) + count += contentMediaCount(req.Metadata["content"], urlKey) + } + if singleKey == "image" { + count += len(req.Images) + len(req.ReferenceImages) + if strings.TrimSpace(req.Image) != "" { + count++ + } + } + return count +} + +func contentMediaCount(value any, mediaKey string) int { + items, ok := value.([]any) + if !ok { + return 0 + } + count := 0 + expectedType := strings.TrimSuffix(mediaKey, "_url") + "_url" + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(firstBillingString(itemMap, "type"))) + if itemType != "" && itemType != expectedType { + continue + } + if usableBillingMedia(itemMap[mediaKey]) || usableBillingMedia(itemMap[strings.TrimSuffix(mediaKey, "_url")]) || usableBillingMedia(itemMap["url"]) { + count++ + } + } + return count +} + +func setIfPresent(fields map[string]string, key string, values ...string) { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + fields[key] = value + return + } +} + +func setCountField(fields map[string]string, key string, count int) { + if count > 0 { + fields[key] = strconv.Itoa(count) + } +} + +func firstBillingString(raw map[string]any, keys ...string) string { + for _, key := range keys { + if raw == nil { + return "" + } + if value := strings.TrimSpace(common.Interface2String(raw[key])); value != "" { + return value + } + } + return "" +} + +func billingMap(raw map[string]any, key string) (map[string]any, bool) { + if raw == nil { + return nil, false + } + value, ok := raw[key] + if !ok { + return nil, false + } + typed, ok := value.(map[string]any) + return typed, ok +} + +func positiveBillingNumber(raw map[string]any, keys ...string) float64 { + for _, key := range keys { + if raw == nil { + return 0 + } + if value := positiveBillingNumberValue(raw[key]); value > 0 { + return value + } + } + return 0 +} + +func positiveBillingNumberValue(value any) float64 { + switch typed := value.(type) { + case int: + return positiveFloat(float64(typed)) + case int64: + return positiveFloat(float64(typed)) + case float64: + return positiveFloat(typed) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err == nil { + return positiveFloat(parsed) + } + } + return 0 +} + +func positiveFloat(value float64) float64 { + if value > 0 { + return value + } + return 0 +} + +func billingBool(raw map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + if raw == nil { + return false, false + } + value, ok := raw[key] + if !ok { + continue + } + switch typed := value.(type) { + case bool: + return typed, true + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) + if err == nil { + return parsed, true + } + case int: + return typed != 0, true + case float64: + return typed != 0, true + } + } + return false, false +} + +func usableBillingMedia(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(typed) != "" + case bool: + return typed + case []any: + for _, item := range typed { + if usableBillingMedia(item) { + return true + } + } + return false + case map[string]any: + hasURLLikeKey := false + for _, key := range []string{"url", "uri", "src"} { + if _, ok := typed[key]; ok { + hasURLLikeKey = true + } + if usableBillingMedia(typed[key]) { + return true + } + } + if hasURLLikeKey { + return false + } + for _, value := range typed { + if usableBillingMedia(value) { + return true + } + } + return false + default: + return true + } +} + +func mediaCount(value any) int { + switch typed := value.(type) { + case nil: + return 0 + case string: + if strings.TrimSpace(typed) == "" { + return 0 + } + return 1 + case []any: + count := 0 + for _, item := range typed { + if usableBillingMedia(item) { + count++ + } + } + return count + default: + if usableBillingMedia(value) { + return 1 + } + return 0 + } +} + +func formatBillingNumber(value float64) string { + if value == float64(int64(value)) { + return strconv.FormatInt(int64(value), 10) + } + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/relay/channel/task/taskcommon/generic_billing_test.go b/relay/channel/task/taskcommon/generic_billing_test.go new file mode 100644 index 0000000..bb2dbd2 --- /dev/null +++ b/relay/channel/task/taskcommon/generic_billing_test.go @@ -0,0 +1,237 @@ +package taskcommon + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withGenericTaskRateCards(t *testing.T, cards map[string]task_billing_setting.RateCard) { + t.Helper() + original := task_billing_setting.GetRateCardsCopy() + originalData, err := common.Marshal(original) + require.NoError(t, err) + data, err := common.Marshal(cards) + require.NoError(t, err) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(data), + })) + t.Cleanup(func() { + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(originalData), + })) + }) +} + +func withGenericBillingQuotaPerUnit(t *testing.T, value float64) { + t.Helper() + original := common.QuotaPerUnit + common.QuotaPerUnit = value + t.Cleanup(func() { + common.QuotaPerUnit = original + }) +} + +func TestEstimateGenericTaskBillingUsesConfiguredVideoRateCard(t *testing.T) { + withGenericBillingQuotaPerUnit(t, 1000) + withGenericTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "custom-video-model": { + Vendor: "custom", + Unit: "second", + QuantityField: "duration", + DefaultQuantity: 5, + Strict: true, + Defaults: map[string]string{ + "quality": "std", + "has_audio": "false", + "has_video_input": "false", + }, + Rows: []task_billing_setting.RateCardRow{ + { + ID: "std_720p_audio_video", + Match: map[string]string{ + "quality": "std", + "resolution": "720p", + "has_audio": "true", + "has_video_input": "true", + }, + UnitPrice: 0.8, + }, + }, + }, + }) + + duration := 5 + audio := true + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "custom-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "custom-video-model"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "custom-video-model", + Mode: "std", + DurationSeconds: &duration, + Resolution: "720p", + GenerateAudio: &audio, + Metadata: map[string]any{ + "video_url": "https://example.com/input.mp4", + }, + }) + + got, err := EstimateGenericTaskBilling(c, info, "custom-video") + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "custom-video-model", got.RuleKey) + assert.Equal(t, "std_720p_audio_video", got.RowID) + assert.InDelta(t, 5.0, got.Quantity, 1e-9) + assert.InDelta(t, 4.0, got.TotalPrice, 1e-9) + assert.Equal(t, 4000, got.Quota) + assert.Equal(t, "true", got.Fields["has_audio"]) + assert.Equal(t, "true", got.Fields["generate_audio"]) + assert.Equal(t, "true", got.Fields["has_video_input"]) +} + +func TestEstimateGenericTaskBillingIgnoresMissingRequestWithoutRateCard(t *testing.T) { + withGenericTaskRateCards(t, map[string]task_billing_setting.RateCard{}) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "unconfigured-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "unconfigured-video-model"}, + } + + got, err := EstimateGenericTaskBilling(c, info, "custom-video") + + require.NoError(t, err) + require.Nil(t, got) +} + +func TestEstimateGenericTaskBillingUsesFinalRequestBody(t *testing.T) { + withGenericBillingQuotaPerUnit(t, 1000) + withGenericTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "mapped-video-model": { + Vendor: "custom", + Unit: "call", + Strict: true, + DefaultQuantity: 1, + Defaults: map[string]string{ + "duration": "5", + "resolution": "720p", + "has_video_input": "false", + }, + Rows: []task_billing_setting.RateCardRow{ + { + ID: "final_1080p_10s", + Match: map[string]string{ + "duration": "10", + "resolution": "1080p", + }, + UnitPrice: 2, + }, + }, + }, + }) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"model":"mapped-video-model"}`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "mapped-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "mapped-video-model"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "mapped-video-model", + Duration: 5, + Resolution: "720p", + }) + require.NoError(t, SyncTaskRequestContext(c, []byte(`{ + "model": "mapped-video-model", + "duration_seconds": 10, + "resolution": "1080p" + }`))) + + got, err := EstimateGenericTaskBilling(c, info, "custom-video") + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "final_1080p_10s", got.RowID) + assert.Equal(t, "10", got.Fields["duration"]) + assert.Equal(t, "1080p", got.Fields["resolution"]) + assert.Equal(t, 2000, got.Quota) +} + +func TestEstimateGenericTaskBillingVideoInputRequiresUsableMedia(t *testing.T) { + withGenericBillingQuotaPerUnit(t, 1000) + withGenericTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "content-video-model": { + Vendor: "custom", + Unit: "call", + DefaultQuantity: 1, + Strict: true, + Defaults: map[string]string{ + "has_video_input": "false", + }, + Rows: []task_billing_setting.RateCardRow{ + {ID: "no_video", Match: map[string]string{"has_video_input": "false"}, UnitPrice: 1}, + {ID: "has_video", Match: map[string]string{"has_video_input": "true"}, UnitPrice: 2}, + }, + }, + }) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"model":"content-video-model"}`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "content-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "content-video-model"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "content-video-model", + Metadata: map[string]any{ + "video_url": map[string]any{"url": ""}, + }, + }) + + got, err := EstimateGenericTaskBilling(c, info, "custom-video") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "no_video", got.RowID) + + require.NoError(t, SyncTaskRequestContext(c, []byte(`{ + "model": "content-video-model", + "content": [ + { "type": "video_url", "video_url": { "url": "https://example.com/input.mp4" } } + ] + }`))) + + got, err = EstimateGenericTaskBilling(c, info, "custom-video") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "has_video", got.RowID) + assert.Equal(t, "1", got.Fields["video_count"]) +} diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index 8f3a149..7eb6548 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -107,6 +107,43 @@ func TestBuildConfiguredTaskPassThroughBodyUsesChannelPassThrough(t *testing.T) assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) } +func TestSyncTaskRequestContextReadsOfficialSeedanceFields(t *testing.T) { + c := newJSONTaskContext(`{ + "model": "video-model", + "content": [ + { "type": "text", "text": "official prompt" } + ], + "ratio": "16:9", + "generate_audio": false, + "priority": 0, + "seed": 0 + }`) + + err := SyncTaskRequestContext(c, []byte(`{ + "model": "video-model", + "content": [ + { "type": "text", "text": "official prompt" } + ], + "ratio": "16:9", + "generate_audio": false, + "priority": 0, + "seed": 0 + }`)) + + require.NoError(t, err) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + assert.Equal(t, "16:9", req.Ratio) + require.NotNil(t, req.GenerateAudio) + assert.False(t, *req.GenerateAudio) + require.NotNil(t, req.Priority) + assert.Equal(t, 0, *req.Priority) + require.NotNil(t, req.Seed) + assert.Equal(t, 0, *req.Seed) + require.Len(t, req.Content, 1) + assert.Equal(t, "official prompt", req.Content[0]["text"]) +} + func TestBuildConfiguredTaskPassThroughBodyFallsBackWithoutChannelPassThrough(t *testing.T) { c := newJSONTaskContext(`{"model":"video-model","prompt":"test"}`) info := &relaycommon.RelayInfo{ diff --git a/relay/channel/task/taskcommon/request_body.go b/relay/channel/task/taskcommon/request_body.go index 380e245..679dde3 100644 --- a/relay/channel/task/taskcommon/request_body.go +++ b/relay/channel/task/taskcommon/request_body.go @@ -71,10 +71,10 @@ func ApplyTaskParamOverrideBytes(requestBody io.Reader, info *relaycommon.RelayI } func SyncTaskRequestContext(c *gin.Context, data []byte) error { - relaycommon.StoreTaskSubmitRequestBody(c, data) if c == nil || c.Request == nil || len(bytes.TrimSpace(data)) == 0 || !common.IsJsonObject(string(data)) { return nil } + relaycommon.StoreTaskSubmitRequestBody(c, data) var raw map[string]any if err := common.Unmarshal(data, &raw); err != nil { return err @@ -107,8 +107,16 @@ func mergeTaskSubmitReq(req *relaycommon.TaskSubmitReq, raw map[string]any) { copyString(raw, "image_tail", &req.EndImage) copyString(raw, "size", &req.Size) copyString(raw, "aspect_ratio", &req.Size) + copyString(raw, "ratio", &req.Ratio) copyString(raw, "seconds", &req.Seconds) copyString(raw, "input_reference", &req.InputReference) + if content, ok := mapSliceValue(raw["content"]); ok { + req.Content = content + } + copyString(raw, "callback_url", &req.CallbackURL) + copyBoolPtr(raw, "return_last_frame", &req.ReturnLastFrame) + copyString(raw, "service_tier", &req.ServiceTier) + copyIntPtr(raw, "execution_expires_after", &req.ExecutionExpiresAfter) copyString(raw, "capability", &req.Capability) copyString(raw, "control_mode", &req.ControlMode) copyString(raw, "input_mode", &req.InputMode) @@ -117,6 +125,17 @@ func mergeTaskSubmitReq(req *relaycommon.TaskSubmitReq, raw map[string]any) { copyIntPtr(raw, "duration_seconds", &req.DurationSeconds) copyBoolPtr(raw, "with_audio", &req.WithAudio) copyBoolPtr(raw, "generate_audio", &req.WithAudio) + copyBoolPtr(raw, "generate_audio", &req.GenerateAudio) + copyBoolPtr(raw, "draft", &req.Draft) + if tools, ok := mapSliceValue(raw["tools"]); ok { + req.Tools = tools + } + copyString(raw, "safety_identifier", &req.SafetyIdentifier) + copyIntPtr(raw, "priority", &req.Priority) + copyIntPtr(raw, "frames", &req.Frames) + copyIntPtr(raw, "seed", &req.Seed) + copyBoolPtr(raw, "camera_fixed", &req.CameraFixed) + copyBoolPtr(raw, "watermark", &req.Watermark) if images, ok := stringSliceValue(raw["images"]); ok { req.Images = images } @@ -276,6 +295,22 @@ func stringSliceValue(value any) ([]string, bool) { return result, true } +func mapSliceValue(value any) ([]map[string]any, bool) { + items, ok := value.([]any) + if !ok { + return nil, false + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + m, ok := mapValue(item) + if !ok { + return nil, false + } + result = append(result, m) + } + return result, true +} + func mapValue(value any) (map[string]any, bool) { switch v := value.(type) { case map[string]any: diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 875bc9a..b52bd61 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -686,25 +686,40 @@ type TaskRelayInfo struct { } type TaskSubmitReq struct { - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` - Mode string `json:"mode,omitempty"` - Image string `json:"image,omitempty"` - Images []string `json:"images,omitempty"` - Size string `json:"size,omitempty"` - Duration int `json:"duration,omitempty"` - Seconds string `json:"seconds,omitempty"` - InputReference string `json:"input_reference,omitempty"` - AspectRatio string `json:"aspect_ratio,omitempty"` - Capability string `json:"capability,omitempty"` - ControlMode string `json:"control_mode,omitempty"` - DurationSeconds *int `json:"duration_seconds,omitempty"` - EndImage string `json:"end_image,omitempty"` - InputMode string `json:"input_mode,omitempty"` - ReferenceImages []string `json:"reference_images,omitempty"` - Resolution string `json:"resolution,omitempty"` - WithAudio *bool `json:"with_audio,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Images []string `json:"images,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Seconds string `json:"seconds,omitempty"` + InputReference string `json:"input_reference,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Ratio string `json:"ratio,omitempty"` + Content []map[string]any `json:"content,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + ExecutionExpiresAfter *int `json:"execution_expires_after,omitempty"` + Capability string `json:"capability,omitempty"` + ControlMode string `json:"control_mode,omitempty"` + DurationSeconds *int `json:"duration_seconds,omitempty"` + EndImage string `json:"end_image,omitempty"` + InputMode string `json:"input_mode,omitempty"` + ReferenceImages []string `json:"reference_images,omitempty"` + Resolution string `json:"resolution,omitempty"` + WithAudio *bool `json:"with_audio,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Draft *bool `json:"draft,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + Priority *int `json:"priority,omitempty"` + Frames *int `json:"frames,omitempty"` + Seed *int `json:"seed,omitempty"` + CameraFixed *bool `json:"camera_fixed,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (t *TaskSubmitReq) GetPrompt() string { diff --git a/relay/relay_task.go b/relay/relay_task.go index 633fe9d..a24b437 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -198,16 +198,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 6. Prefer a parameterized rate card when the adaptor can normalize the // request. Legacy task models continue to use OtherRatios. taskBillingOverride := false - if estimator, ok := adaptor.(taskBillingEstimator); ok { - taskBilling, err := estimator.EstimateTaskBilling(c, info) - if err != nil { - return nil, service.TaskErrorWrapper(err, "task_billing_rule_error", http.StatusBadRequest) - } - if taskBilling != nil { - info.TaskBilling = taskBilling - info.PriceData.Quota = taskBilling.Quota - taskBillingOverride = true - } + taskBilling, err := estimateTaskBilling(c, info, adaptor, platform) + if err != nil { + return nil, service.TaskErrorWrapper(err, "task_billing_rule_error", http.StatusBadRequest) + } + if taskBilling != nil { + info.TaskBilling = taskBilling + info.PriceData.Quota = taskBilling.Quota + taskBillingOverride = true } if !taskBillingOverride { @@ -281,13 +279,30 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } +func estimateTaskBilling(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor, platform constant.TaskPlatform) (*types.TaskBillingResult, error) { + if estimator, ok := adaptor.(taskBillingEstimator); ok { + taskBilling, err := estimator.EstimateTaskBilling(c, info) + if err != nil || taskBilling != nil { + return taskBilling, err + } + } + channelName := "" + if adaptor != nil { + channelName = adaptor.GetChannelName() + } + if channelName == "" { + channelName = string(platform) + } + return taskcommon.EstimateGenericTaskBilling(c, info, channelName) +} + func prepareTaskSubmitRequestBody(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) (io.Reader, *dto.TaskError) { relaycommon.ClearTaskSubmitRequestBody(c) requestBody, err := buildTaskSubmitRequestBody(c, info, adaptor) if err != nil { return nil, taskErrorFromBuildRequestError(err) } - if info == nil || len(info.ParamOverride) == 0 { + if c == nil || c.Request == nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") { return requestBody, nil } bodyBytes, err := io.ReadAll(requestBody) diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index 9327738..033bb16 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -7,14 +7,44 @@ import ( "strings" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/relay/channel/task/doubao" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func withRelayTaskRateCards(t *testing.T, cards map[string]task_billing_setting.RateCard) { + t.Helper() + original := task_billing_setting.GetRateCardsCopy() + originalData, err := common.Marshal(original) + require.NoError(t, err) + data, err := common.Marshal(cards) + require.NoError(t, err) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(data), + })) + t.Cleanup(func() { + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(originalData), + })) + }) +} + +func withRelayTaskQuotaPerUnit(t *testing.T, value float64) { + t.Helper() + original := common.QuotaPerUnit + common.QuotaPerUnit = value + t.Cleanup(func() { + common.QuotaPerUnit = original + }) +} + func TestPrepareTaskSubmitRequestBodyMakesParamOverrideVisibleToBilling(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) @@ -60,6 +90,61 @@ func TestPrepareTaskSubmitRequestBodyMakesParamOverrideVisibleToBilling(t *testi assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) } +func TestEstimateTaskBillingFallsBackToGenericRateCard(t *testing.T) { + withRelayTaskQuotaPerUnit(t, 1000) + withRelayTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "custom-video-model": { + Vendor: "custom", + Unit: "second", + QuantityField: "duration", + DefaultQuantity: 5, + Strict: true, + Defaults: map[string]string{ + "resolution": "720p", + "has_audio": "false", + "has_video_input": "false", + }, + Rows: []task_billing_setting.RateCardRow{ + { + ID: "720p_no_audio", + Match: map[string]string{ + "resolution": "720p", + "has_audio": "false", + }, + UnitPrice: 0.5, + }, + }, + }, + }) + + duration := 6 + audio := false + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "custom-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "custom-video-model"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "custom-video-model", + DurationSeconds: &duration, + Resolution: "720p", + GenerateAudio: &audio, + }) + + got, err := estimateTaskBilling(c, info, &doubao.TaskAdaptor{}, constant.TaskPlatform("custom-video")) + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "custom-video-model", got.RuleKey) + assert.Equal(t, "720p_no_audio", got.RowID) + assert.InDelta(t, 6.0, got.Quantity, 1e-9) + assert.Equal(t, 3000, got.Quota) +} + func TestPrepareTaskSubmitRequestBodyParamOverrideReturnErrorIsLocal(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) From 2253cc58912bd40794136833466129e119187c2d Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 4 Jul 2026 12:42:37 +0800 Subject: [PATCH 2/3] v1.0.4-preview.1 --- .../task/taskcommon/generic_billing.go | 7 ++- .../task/taskcommon/generic_billing_test.go | 47 +++++++++++++++++++ relay/relay_task.go | 6 ++- relay/relay_task_test.go | 44 +++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/relay/channel/task/taskcommon/generic_billing.go b/relay/channel/task/taskcommon/generic_billing.go index 9b1be86..12f9bbe 100644 --- a/relay/channel/task/taskcommon/generic_billing.go +++ b/relay/channel/task/taskcommon/generic_billing.go @@ -227,8 +227,11 @@ func genericBillingMediaCount(req relaycommon.TaskSubmitReq, raw map[string]any, count += contentMediaCount(req.Metadata["content"], urlKey) } if singleKey == "image" { - count += len(req.Images) + len(req.ReferenceImages) - if strings.TrimSpace(req.Image) != "" { + count += len(req.ReferenceImages) + if len(raw) == 0 || raw[listKey] == nil { + count += len(req.Images) + } + if (len(raw) == 0 || raw[singleKey] == nil) && strings.TrimSpace(req.Image) != "" { count++ } } diff --git a/relay/channel/task/taskcommon/generic_billing_test.go b/relay/channel/task/taskcommon/generic_billing_test.go index bb2dbd2..43074f8 100644 --- a/relay/channel/task/taskcommon/generic_billing_test.go +++ b/relay/channel/task/taskcommon/generic_billing_test.go @@ -235,3 +235,50 @@ func TestEstimateGenericTaskBillingVideoInputRequiresUsableMedia(t *testing.T) { assert.Equal(t, "has_video", got.RowID) assert.Equal(t, "1", got.Fields["video_count"]) } + +func TestEstimateGenericTaskBillingDoesNotDoubleCountRawImages(t *testing.T) { + withGenericBillingQuotaPerUnit(t, 1000) + withGenericTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "image-count-video-model": { + Vendor: "custom", + Unit: "call", + DefaultQuantity: 1, + Strict: true, + Defaults: map[string]string{ + "image_count": "0", + }, + Rows: []task_billing_setting.RateCardRow{ + {ID: "three_images", Match: map[string]string{"image_count": "3"}, UnitPrice: 1}, + }, + }, + }) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"model":"image-count-video-model"}`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "image-count-video-model", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "image-count-video-model"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "image-count-video-model", + Images: []string{"https://example.com/a.png", "https://example.com/b.png"}, + ReferenceImages: []string{"https://example.com/ref.png"}, + }) + require.NoError(t, SyncTaskRequestContext(c, []byte(`{ + "model": "image-count-video-model", + "images": ["https://example.com/a.png", "https://example.com/b.png"], + "reference_images": ["https://example.com/ref.png"] + }`))) + + got, err := EstimateGenericTaskBilling(c, info, "custom-video") + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "three_images", got.RowID) + assert.Equal(t, "3", got.Fields["image_count"]) +} diff --git a/relay/relay_task.go b/relay/relay_task.go index a24b437..b046ef6 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -302,7 +302,11 @@ func prepareTaskSubmitRequestBody(c *gin.Context, info *relaycommon.RelayInfo, a if err != nil { return nil, taskErrorFromBuildRequestError(err) } - if c == nil || c.Request == nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") { + if c == nil || c.Request == nil { + return requestBody, nil + } + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") && + (info == nil || len(info.ParamOverride) == 0) { return requestBody, nil } bodyBytes, err := io.ReadAll(requestBody) diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index 033bb16..7dbed28 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -90,6 +90,50 @@ func TestPrepareTaskSubmitRequestBodyMakesParamOverrideVisibleToBilling(t *testi assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) } +func TestPrepareTaskSubmitRequestBodyMakesMultipartParamOverrideVisibleToBilling(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`--boundary--`)) + c.Request.Header.Set("Content-Type", "multipart/form-data; boundary=boundary") + + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "resolution", + "mode": "set", + "value": "1080p", + }, + }, + }, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Metadata: map[string]interface{}{ + "resolution": "720p", + }, + }) + + requestBody, taskErr := prepareTaskSubmitRequestBody(c, info, &doubao.TaskAdaptor{}) + + require.Nil(t, taskErr) + body, err := io.ReadAll(requestBody) + require.NoError(t, err) + assert.Contains(t, string(body), `"resolution":"1080p"`) + + finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c) + require.True(t, ok) + assert.Contains(t, string(finalBody), `"resolution":"1080p"`) + ratios := (&doubao.TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) +} + func TestEstimateTaskBillingFallsBackToGenericRateCard(t *testing.T) { withRelayTaskQuotaPerUnit(t, 1000) withRelayTaskRateCards(t, map[string]task_billing_setting.RateCard{ From f21a8dc5a7ee46dd373ed1686a0e5de4b0c5bc09 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 4 Jul 2026 13:37:29 +0800 Subject: [PATCH 3/3] v1.0.4-preview.1 --- relay/channel/task/doubao/adaptor.go | 21 ++++++++++-------- .../task/doubao/adaptor_billing_test.go | 22 +++++++++++++++---- .../task/taskcommon/generic_billing.go | 9 +++++++- .../task/taskcommon/path_config_test.go | 14 +++--------- relay/channel/task/taskcommon/request_body.go | 18 ++++++++++----- relay/common/relay_info.go | 8 +++---- 6 files changed, 58 insertions(+), 34 deletions(-) diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index ab19047..690ab4d 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -45,9 +45,9 @@ type MediaURL struct { type requestPayload struct { Model string `json:"model"` Content []ContentItem `json:"content,omitempty"` - CallbackURL string `json:"callback_url,omitempty"` + CallbackURL *string `json:"callback_url,omitempty"` ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"` GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"` Draft *dto.BoolValue `json:"draft,omitempty"` @@ -458,13 +458,13 @@ func applyTopLevelSeedanceOptions(req *relaycommon.TaskSubmitReq, r *requestPayl } r.Content = items } - if req.CallbackURL != "" { + if req.CallbackURL != nil { r.CallbackURL = req.CallbackURL } if req.ReturnLastFrame != nil { r.ReturnLastFrame = lo.ToPtr(dto.BoolValue(*req.ReturnLastFrame)) } - if req.ServiceTier != "" { + if req.ServiceTier != nil { r.ServiceTier = req.ServiceTier } if req.ExecutionExpiresAfter != nil { @@ -473,7 +473,10 @@ func applyTopLevelSeedanceOptions(req *relaycommon.TaskSubmitReq, r *requestPayl if req.Resolution != "" { r.Resolution = lo.ToPtr(req.Resolution) } - if ratio := firstNonEmptyString(req.Ratio, req.AspectRatio, req.Size); ratio != "" { + if req.Ratio != nil { + ratio := strings.TrimSpace(*req.Ratio) + r.Ratio = lo.ToPtr(ratio) + } else if ratio := firstNonEmptyString(req.AspectRatio, req.Size); ratio != "" { r.Ratio = lo.ToPtr(ratio) } if req.Duration > 0 { @@ -497,8 +500,8 @@ func applyTopLevelSeedanceOptions(req *relaycommon.TaskSubmitReq, r *requestPayl } r.Tools = tools } - if req.SafetyIdentifier != "" { - r.SafetyIdentifier = lo.ToPtr(req.SafetyIdentifier) + if req.SafetyIdentifier != nil { + r.SafetyIdentifier = req.SafetyIdentifier } if req.Priority != nil { r.Priority = lo.ToPtr(dto.IntValue(*req.Priority)) @@ -548,8 +551,8 @@ func topLevelTools(raw []map[string]any) ([]struct { func firstNonEmptyString(values ...string) string { for _, value := range values { - if strings.TrimSpace(value) != "" { - return value + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed } } return "" diff --git a/relay/channel/task/doubao/adaptor_billing_test.go b/relay/channel/task/doubao/adaptor_billing_test.go index fd2ecdb..534f8ce 100644 --- a/relay/channel/task/doubao/adaptor_billing_test.go +++ b/relay/channel/task/doubao/adaptor_billing_test.go @@ -360,10 +360,12 @@ func TestConvertToRequestPayloadUsesTopLevelSeedanceFields(t *testing.T) { require.NotNil(t, payload.Content[1].ImageURL) assert.Equal(t, "https://example.com/first.png", payload.Content[1].ImageURL.URL) assert.Equal(t, "first_frame", payload.Content[1].Role) - assert.Equal(t, "https://example.com/callback", payload.CallbackURL) + require.NotNil(t, payload.CallbackURL) + assert.Equal(t, "https://example.com/callback", *payload.CallbackURL) require.NotNil(t, payload.ReturnLastFrame) assert.False(t, bool(*payload.ReturnLastFrame)) - assert.Equal(t, "default", payload.ServiceTier) + require.NotNil(t, payload.ServiceTier) + assert.Equal(t, "default", *payload.ServiceTier) require.NotNil(t, payload.ExecutionExpiresAfter) assert.Equal(t, 3600, int(*payload.ExecutionExpiresAfter)) require.NotNil(t, payload.Ratio) @@ -413,9 +415,12 @@ func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing err := common.Unmarshal([]byte(`{ "model": "doubao-seedance-2-0-260128", "prompt": "test", + "ratio": "", + "callback_url": "", + "service_tier": "", + "safety_identifier": "", "metadata": { - "resolution": "", - "ratio": "" + "resolution": "" } }`), &req) require.NoError(t, err) @@ -423,15 +428,24 @@ func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) require.NoError(t, err) require.NotNil(t, payload) + require.NotNil(t, payload.CallbackURL) + assert.Equal(t, "", *payload.CallbackURL) + require.NotNil(t, payload.ServiceTier) + assert.Equal(t, "", *payload.ServiceTier) require.NotNil(t, payload.Resolution) assert.Equal(t, "", *payload.Resolution) require.NotNil(t, payload.Ratio) assert.Equal(t, "", *payload.Ratio) + require.NotNil(t, payload.SafetyIdentifier) + assert.Equal(t, "", *payload.SafetyIdentifier) data, err := common.Marshal(payload) require.NoError(t, err) + assert.Contains(t, string(data), `"callback_url":""`) + assert.Contains(t, string(data), `"service_tier":""`) assert.Contains(t, string(data), `"resolution":""`) assert.Contains(t, string(data), `"ratio":""`) + assert.Contains(t, string(data), `"safety_identifier":""`) } func TestValidateConfiguredTaskProtocolAllowsPromptlessMediaRequest(t *testing.T) { diff --git a/relay/channel/task/taskcommon/generic_billing.go b/relay/channel/task/taskcommon/generic_billing.go index 12f9bbe..9478d45 100644 --- a/relay/channel/task/taskcommon/generic_billing.go +++ b/relay/channel/task/taskcommon/generic_billing.go @@ -89,7 +89,7 @@ func genericBillingFields(req relaycommon.TaskSubmitReq, raw map[string]any) map if params, ok := billingMap(raw, "parameters"); ok { setIfPresent(fields, "resolution", firstBillingString(params, "resolution"), "") } - setIfPresent(fields, "ratio", firstBillingString(raw, "ratio", "aspect_ratio", "aspectRatio", "size"), req.Ratio, req.AspectRatio, req.Size, firstBillingString(req.Metadata, "ratio", "aspect_ratio", "aspectRatio", "size")) + setIfPresent(fields, "ratio", firstBillingString(raw, "ratio", "aspect_ratio", "aspectRatio", "size"), stringPtrValue(req.Ratio), req.AspectRatio, req.Size, firstBillingString(req.Metadata, "ratio", "aspect_ratio", "aspectRatio", "size")) if params, ok := billingMap(raw, "parameters"); ok { setIfPresent(fields, "ratio", firstBillingString(params, "ratio", "aspect_ratio", "aspectRatio", "size"), "") } @@ -272,6 +272,13 @@ func setIfPresent(fields map[string]string, key string, values ...string) { } } +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return *value +} + func setCountField(fields map[string]string, key string, count int) { if count > 0 { fields[key] = strconv.Itoa(count) diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index 7eb6548..584cf3c 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -108,16 +108,7 @@ func TestBuildConfiguredTaskPassThroughBodyUsesChannelPassThrough(t *testing.T) } func TestSyncTaskRequestContextReadsOfficialSeedanceFields(t *testing.T) { - c := newJSONTaskContext(`{ - "model": "video-model", - "content": [ - { "type": "text", "text": "official prompt" } - ], - "ratio": "16:9", - "generate_audio": false, - "priority": 0, - "seed": 0 - }`) + c := newJSONTaskContext(`{}`) err := SyncTaskRequestContext(c, []byte(`{ "model": "video-model", @@ -133,7 +124,8 @@ func TestSyncTaskRequestContextReadsOfficialSeedanceFields(t *testing.T) { require.NoError(t, err) req, err := relaycommon.GetTaskRequest(c) require.NoError(t, err) - assert.Equal(t, "16:9", req.Ratio) + require.NotNil(t, req.Ratio) + assert.Equal(t, "16:9", *req.Ratio) require.NotNil(t, req.GenerateAudio) assert.False(t, *req.GenerateAudio) require.NotNil(t, req.Priority) diff --git a/relay/channel/task/taskcommon/request_body.go b/relay/channel/task/taskcommon/request_body.go index 679dde3..3f8cd76 100644 --- a/relay/channel/task/taskcommon/request_body.go +++ b/relay/channel/task/taskcommon/request_body.go @@ -107,15 +107,15 @@ func mergeTaskSubmitReq(req *relaycommon.TaskSubmitReq, raw map[string]any) { copyString(raw, "image_tail", &req.EndImage) copyString(raw, "size", &req.Size) copyString(raw, "aspect_ratio", &req.Size) - copyString(raw, "ratio", &req.Ratio) + copyStringPtr(raw, "ratio", &req.Ratio) copyString(raw, "seconds", &req.Seconds) copyString(raw, "input_reference", &req.InputReference) if content, ok := mapSliceValue(raw["content"]); ok { req.Content = content } - copyString(raw, "callback_url", &req.CallbackURL) + copyStringPtr(raw, "callback_url", &req.CallbackURL) copyBoolPtr(raw, "return_last_frame", &req.ReturnLastFrame) - copyString(raw, "service_tier", &req.ServiceTier) + copyStringPtr(raw, "service_tier", &req.ServiceTier) copyIntPtr(raw, "execution_expires_after", &req.ExecutionExpiresAfter) copyString(raw, "capability", &req.Capability) copyString(raw, "control_mode", &req.ControlMode) @@ -124,13 +124,12 @@ func mergeTaskSubmitReq(req *relaycommon.TaskSubmitReq, raw map[string]any) { copyInt(raw, "duration", &req.Duration) copyIntPtr(raw, "duration_seconds", &req.DurationSeconds) copyBoolPtr(raw, "with_audio", &req.WithAudio) - copyBoolPtr(raw, "generate_audio", &req.WithAudio) copyBoolPtr(raw, "generate_audio", &req.GenerateAudio) copyBoolPtr(raw, "draft", &req.Draft) if tools, ok := mapSliceValue(raw["tools"]); ok { req.Tools = tools } - copyString(raw, "safety_identifier", &req.SafetyIdentifier) + copyStringPtr(raw, "safety_identifier", &req.SafetyIdentifier) copyIntPtr(raw, "priority", &req.Priority) copyIntPtr(raw, "frames", &req.Frames) copyIntPtr(raw, "seed", &req.Seed) @@ -212,6 +211,15 @@ func copyString(raw map[string]any, key string, target *string) bool { return true } +func copyStringPtr(raw map[string]any, key string, target **string) bool { + value, ok := stringValue(raw[key]) + if !ok { + return false + } + *target = &value + return true +} + func copyInt(raw map[string]any, key string, target *int) bool { value, ok := intValue(raw[key]) if !ok { diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index b52bd61..d8c9709 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -696,11 +696,11 @@ type TaskSubmitReq struct { Seconds string `json:"seconds,omitempty"` InputReference string `json:"input_reference,omitempty"` AspectRatio string `json:"aspect_ratio,omitempty"` - Ratio string `json:"ratio,omitempty"` + Ratio *string `json:"ratio,omitempty"` Content []map[string]any `json:"content,omitempty"` - CallbackURL string `json:"callback_url,omitempty"` + CallbackURL *string `json:"callback_url,omitempty"` ReturnLastFrame *bool `json:"return_last_frame,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` ExecutionExpiresAfter *int `json:"execution_expires_after,omitempty"` Capability string `json:"capability,omitempty"` ControlMode string `json:"control_mode,omitempty"` @@ -713,7 +713,7 @@ type TaskSubmitReq struct { GenerateAudio *bool `json:"generate_audio,omitempty"` Draft *bool `json:"draft,omitempty"` Tools []map[string]any `json:"tools,omitempty"` - SafetyIdentifier string `json:"safety_identifier,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` Priority *int `json:"priority,omitempty"` Frames *int `json:"frames,omitempty"` Seed *int `json:"seed,omitempty"`