From 56400b6c6123c2b5904bed74502d238d5a829346 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 2 Jul 2026 10:11:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20KlingAiping=20=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E9=80=82=E9=85=8D=20+=20=E5=B1=95=E7=A4=BA=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 KlingAiping 渠道 (ChannelTypeKlingAiping = 59) - 新增 KlingAiping 原生路由 (text2video/image2video/omni-video 等) - 新增 KlingAiping adaptor (relay/channel/task/kling/aiping/) - 前端模型定价页面新增「真实计费 / 展示价格」Tab 切换 - 新增 DisplayPricing 组件,支持模型展示价格的增删改 - 新增 displayPricingConfig 工具函数及测试 - 修复 KlingAiping 路由路径匹配与 task 错误响应 Co-Authored-By: Claude --- constant/channel.go | 3 + controller/kling_aiping_native.go | 452 ++++++++++++++++++ controller/kling_aiping_native_test.go | 235 +++++++++ controller/relay.go | 32 +- relay/channel/task/kling/aiping/adaptor.go | 341 +++++++++++++ .../channel/task/kling/aiping/adaptor_test.go | 168 +++++++ relay/channel/task/kling/aiping/routes.go | 124 +++++ relay/relay_adaptor.go | 3 + router/video-router.go | 46 ++ router/video_router_test.go | 41 ++ web/src/constants/channel.constants.js | 5 + web/src/helpers/render.jsx | 1 + .../Operation/ModelPricing/DisplayPricing.jsx | 277 +++++++++++ .../ModelPricing/displayPricingConfig.js | 61 +++ .../ModelPricing/displayPricingConfig.test.js | 49 ++ .../Setting/Operation/ModelPricing/index.jsx | 121 ++++- 16 files changed, 1934 insertions(+), 25 deletions(-) create mode 100644 controller/kling_aiping_native.go create mode 100644 controller/kling_aiping_native_test.go create mode 100644 relay/channel/task/kling/aiping/adaptor.go create mode 100644 relay/channel/task/kling/aiping/adaptor_test.go create mode 100644 relay/channel/task/kling/aiping/routes.go create mode 100644 router/video_router_test.go create mode 100644 web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js create mode 100644 web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js diff --git a/constant/channel.go b/constant/channel.go index 4798f99..d54666a 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -56,6 +56,7 @@ const ( ChannelTypeReplicate = 56 ChannelTypeCodex = 57 ChannelTypeDoubaoVideoCompatibleAiping = 58 + ChannelTypeKlingAiping = 59 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -120,6 +121,7 @@ var ChannelBaseURLs = []string{ "https://api.replicate.com", //56 "https://chatgpt.com", //57 "", //58 + "https://aiping.cn/api", //59 } var ChannelTypeNames = map[int]string{ @@ -178,6 +180,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", ChannelTypeDoubaoVideoCompatibleAiping: "DoubaoVideoCompatibleAiping", + ChannelTypeKlingAiping: "KlingAiping", } func GetChannelTypeName(channelType int) string { diff --git a/controller/kling_aiping_native.go b/controller/kling_aiping_native.go new file mode 100644 index 0000000..fcc6073 --- /dev/null +++ b/controller/kling_aiping_native.go @@ -0,0 +1,452 @@ +package controller + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + klingaiping "github.com/QuantumNous/new-api/relay/channel/task/kling/aiping" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func KlingAipingNativeTaskSubmit(c *gin.Context) { + route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindSubmit) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) + return + } + + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) + return + } + payload, err := readJSONPayload(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + + configureKlingAipingTaskRelayInfo(c, relayInfo, route, payload) + + relayTaskWithInfo(c, relayInfo) +} + +func configureKlingAipingTaskRelayInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo, route klingaiping.Route, payload map[string]any) { + // Native Kling routes bypass Distribute(), so force relayTaskWithInfo to + // select a channel instead of reading a preselected one from context. + if relayInfo.ChannelMeta == nil { + relayInfo.ChannelMeta = &relaycommon.ChannelMeta{} + } + modelName := resolveKlingAipingModel(payload, route) + relayInfo.OriginModelName = modelName + relayInfo.Action = route.Action + relaycommon.StoreTaskRequest(c, relayInfo, route.Action, relaycommon.TaskSubmitReq{ + Model: modelName, + Prompt: stringFromMap(payload, "prompt"), + Seconds: fmt.Sprint(payload["seconds"]), + Metadata: payload, + }) +} + +func KlingAipingNativeTaskFetch(c *gin.Context) { + route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindFetch) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) + return + } + task, ok := getKlingAipingUserTask(c, c.Param("task_id"), route.Action) + if !ok { + return + } + refreshKlingAipingTaskIfNeeded(task) + c.JSON(http.StatusOK, buildKlingAipingTaskPayload(task)) +} + +func KlingAipingNativeTaskList(c *gin.Context) { + route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindList) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) + return + } + pageNum, pageSize, err := parseKlingAipingPage(c) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 422, "message": err.Error()}) + return + } + tasks := model.TaskGetAllUserTask(c.GetInt("id"), (pageNum-1)*pageSize, pageSize, model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeKlingAiping)), + Action: route.Action, + }) + data := make([]any, 0, len(tasks)) + for _, task := range tasks { + data = append(data, taskDataObject(task)) + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "success", + "request_id": c.GetString(common.RequestIdKey), + "data": data, + }) +} + +func KlingAipingNativeProxy(c *gin.Context) { + route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindProxy) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) + return + } + channel, _, err := service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ + Ctx: c, + TokenGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + ModelName: route.BillingModel, + Retry: common.GetPointer(0), + RequiredChannelType: constant.ChannelTypeKlingAiping, + }) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": err.Error()}) + return + } + if setupErr := middleware.SetupContextForSelectedChannel(c, channel, route.BillingModel); setupErr != nil { + c.JSON(setupErr.StatusCode, gin.H{"code": setupErr.GetErrorCode(), "message": setupErr.Error()}) + return + } + + resp, err := doKlingAipingProxyRequest(c, route, channel) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"code": 502, "message": err.Error()}) + return + } + defer resp.Body.Close() + copyProxyResponse(c, resp) +} + +func readJSONPayload(c *gin.Context) (map[string]any, error) { + body, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + data, err := body.Bytes() + if err != nil { + return nil, err + } + _, _ = body.Seek(0, io.SeekStart) + c.Request.Body = io.NopCloser(body) + payload := map[string]any{} + if strings.TrimSpace(string(data)) == "" { + return payload, nil + } + if err := common.Unmarshal(data, &payload); err != nil { + return nil, err + } + return payload, nil +} + +func resolveKlingAipingModel(payload map[string]any, route klingaiping.Route) string { + if modelName := stringFromMap(payload, "model_name"); modelName != "" { + return modelName + } + if modelName := stringFromMap(payload, "model"); modelName != "" { + return modelName + } + if route.BillingModel != "" { + return route.BillingModel + } + return "kling-v3" +} + +func stringFromMap(payload map[string]any, key string) string { + if value, ok := payload[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func getKlingAipingUserTask(c *gin.Context, taskID string, action string) (*model.Task, bool) { + task, exist, err := model.GetByTaskId(c.GetInt("id"), taskID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) + return nil, false + } + if !exist || task.Platform != constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeKlingAiping)) || task.Action != action { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "task not found"}) + return nil, false + } + return task, true +} + +func refreshKlingAipingTaskIfNeeded(task *model.Task) { + if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure { + return + } + channelModel, err := model.GetChannelById(task.ChannelId, true) + if err != nil || channelModel == nil { + return + } + adaptor := relay.GetTaskAdaptor(task.Platform) + if adaptor == nil { + return + } + resp, err := adaptor.FetchTask(channelModel.GetBaseURL(), channelModel.Key, map[string]any{ + "task_id": task.GetUpstreamTaskID(), + "action": task.Action, + }, channelModel.GetSetting().Proxy) + if err != nil || resp == nil { + return + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + taskInfo, err := adaptor.ParseTaskResult(body) + if err != nil || taskInfo == nil { + return + } + snap := task.Snapshot() + task.Data = body + if taskInfo.Status != "" { + task.Status = model.TaskStatus(taskInfo.Status) + } + if taskInfo.Progress != "" { + task.Progress = taskInfo.Progress + } + if taskInfo.Url != "" { + task.PrivateData.ResultURL = taskInfo.Url + } + if !snap.Equal(task.Snapshot()) { + _, _ = task.UpdateWithStatus(snap.Status) + } +} + +func buildKlingAipingTaskPayload(task *model.Task) map[string]any { + payload := map[string]any{ + "code": 0, + "message": "success", + "data": taskDataObject(task), + } + return payload +} + +func taskDataObject(task *model.Task) map[string]any { + payload := map[string]any{} + _ = common.Unmarshal(task.Data, &payload) + delete(payload, "aiping_id") + data, _ := payload["data"].(map[string]any) + if data == nil { + data = map[string]any{} + } + data["task_id"] = task.TaskID + if _, ok := data["task_status"]; !ok { + data["task_status"] = mapKlingAipingTaskStatus(task.Status) + } + if _, ok := data["task_status_msg"]; !ok { + data["task_status_msg"] = task.FailReason + } + if _, ok := data["created_at"]; !ok && task.CreatedAt != 0 { + data["created_at"] = task.CreatedAt + } + if _, ok := data["updated_at"]; !ok && task.UpdatedAt != 0 { + data["updated_at"] = task.UpdatedAt + } + ensureKlingAipingWatermarkURL(data) + return data +} + +func mapKlingAipingTaskStatus(status model.TaskStatus) string { + switch status { + case model.TaskStatusSubmitted, model.TaskStatusQueued: + return "submitted" + case model.TaskStatusInProgress: + return "processing" + case model.TaskStatusSuccess: + return "succeed" + case model.TaskStatusFailure: + return "failed" + default: + return "processing" + } +} + +func ensureKlingAipingWatermarkURL(data map[string]any) { + taskResult, _ := data["task_result"].(map[string]any) + if taskResult == nil { + return + } + videos, _ := taskResult["videos"].([]any) + for _, videoAny := range videos { + video, _ := videoAny.(map[string]any) + if video == nil { + continue + } + if _, ok := video["watermark_url"]; !ok { + video["watermark_url"] = "" + } + } +} + +func parseKlingAipingPage(c *gin.Context) (int, int, error) { + pageNum := parseIntDefault(c.Query("pageNum"), 1) + pageSize := parseIntDefault(c.Query("pageSize"), 30) + if pageNum < 1 || pageNum > 1000 { + return 0, 0, fmt.Errorf("pageNum must be in [1, 1000]") + } + if pageSize < 1 || pageSize > 500 { + return 0, 0, fmt.Errorf("pageSize must be in [1, 500]") + } + return pageNum, pageSize, nil +} + +func parseIntDefault(raw string, fallback int) int { + if strings.TrimSpace(raw) == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil { + return -1 + } + return v +} + +func doKlingAipingProxyRequest(c *gin.Context, route klingaiping.Route, channelModel *model.Channel) (*http.Response, error) { + baseURL := strings.TrimRight(channelModel.GetBaseURL(), "/") + upstreamPath := strings.Replace(route.UpstreamPath, ":id", c.Param("id"), 1) + url := baseURL + upstreamPath + if c.Request.URL.RawQuery != "" { + url += "?" + c.Request.URL.RawQuery + } + var body io.Reader + if c.Request.Method != http.MethodGet { + data, err := proxyBodyBytes(c, route) + if err != nil { + return nil, err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequest(c.Request.Method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + key := common.GetContextKeyString(c, constant.ContextKeyChannelKey) + if key == "" { + key = channelModel.Key + } + req.Header.Set("Authorization", "Bearer "+key) + client, err := service.GetHttpClientWithProxy(channelModel.GetSetting().Proxy) + if err != nil { + return nil, err + } + return client.Do(req) +} + +func proxyBodyBytes(c *gin.Context, route klingaiping.Route) ([]byte, error) { + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + data, err := storage.Bytes() + if err != nil { + return nil, err + } + if route.PathPattern == "/v1/elements/delete" { + payload := map[string]any{} + if err := common.Unmarshal(data, &payload); err == nil { + if _, ok := payload["element_id"]; !ok { + if id := stringFromMap(payload, "id"); id != "" { + payload["element_id"] = id + delete(payload, "id") + data, _ = common.Marshal(payload) + } + } + } + } + return data, nil +} + +func copyProxyResponse(c *gin.Context, resp *http.Response) { + if resp.StatusCode >= http.StatusBadRequest { + copyNormalizedProxyError(c, resp) + return + } + for key, values := range resp.Header { + for _, value := range values { + c.Writer.Header().Add(key, value) + } + } + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) +} + +func copyNormalizedProxyError(c *gin.Context, resp *http.Response) { + body, _ := io.ReadAll(resp.Body) + message := strings.TrimSpace(string(body)) + payload := map[string]any{} + if len(body) > 0 && common.Unmarshal(body, &payload) == nil { + if msg := stringFromMap(payload, "message"); msg != "" { + message = msg + } else if msg := stringFromMap(payload, "msg"); msg != "" { + message = msg + } else if detail, ok := payload["detail"]; ok { + if detailMap, ok := detail.(map[string]any); ok { + if msg := stringFromMap(detailMap, "message"); msg != "" { + message = msg + } else if msg := stringFromMap(detailMap, "msg"); msg != "" { + message = msg + } else { + message = fmt.Sprint(detail) + } + } else { + message = fmt.Sprint(detail) + } + } + delete(payload, "msg") + } else { + payload = map[string]any{} + } + if message == "" { + message = resp.Status + } + if _, ok := payload["code"]; !ok { + payload["code"] = resp.StatusCode + } + payload["message"] = message + payload["request_id"] = c.GetString(common.RequestIdKey) + c.JSON(resp.StatusCode, payload) +} + +func normalizeKlingAipingTaskError(taskErr *dto.TaskError) { + if taskErr == nil || strings.TrimSpace(taskErr.Message) == "" { + return + } + payload := map[string]any{} + if common.Unmarshal([]byte(taskErr.Message), &payload) != nil { + return + } + if msg := stringFromMap(payload, "message"); msg != "" { + taskErr.Message = msg + return + } + if msg := stringFromMap(payload, "msg"); msg != "" { + taskErr.Message = msg + return + } + if detail, ok := payload["detail"].(map[string]any); ok { + if msg := stringFromMap(detail, "message"); msg != "" { + taskErr.Message = msg + } + } +} diff --git a/controller/kling_aiping_native_test.go b/controller/kling_aiping_native_test.go new file mode 100644 index 0000000..70147bf --- /dev/null +++ b/controller/kling_aiping_native_test.go @@ -0,0 +1,235 @@ +package controller + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + klingaiping "github.com/QuantumNous/new-api/relay/channel/task/kling/aiping" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestKlingAipingSubmitPreparationUsesModelNameBeforeModel(t *testing.T) { + c := newControllerJSONContext(t, "/v1/videos/text2video", `{ + "model":"Kling-V1.6", + "model_name":"Kling-V2.6", + "prompt":"prompt" + }`) + payload, err := readJSONPayload(c) + require.NoError(t, err) + route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/videos/text2video", klingaiping.RouteKindSubmit) + require.True(t, ok) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} + modelName := resolveKlingAipingModel(payload, route) + relaycommon.StoreTaskRequest(c, info, route.Action, relaycommon.TaskSubmitReq{ + Model: modelName, + Prompt: stringFromMap(payload, "prompt"), + Metadata: payload, + }) + + stored, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + require.Equal(t, "Kling-V2.6", stored.Model) + require.Equal(t, "prompt", stored.Prompt) + require.Equal(t, "Kling-V1.6", stored.Metadata["model"]) + require.Equal(t, "Kling-V2.6", stored.Metadata["model_name"]) +} + +func TestKlingAipingSubmitPreparationDoesNotLockChannel(t *testing.T) { + c := newControllerJSONContext(t, "/v1/kling/general/custom-voices", `{ + "voice_url":"https://example.com/voice.mp3", + "voice_name":"voice" + }`) + payload, err := readJSONPayload(c) + require.NoError(t, err) + route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/kling/general/custom-voices", klingaiping.RouteKindSubmit) + require.True(t, ok) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} + info.OriginModelName = resolveKlingAipingModel(payload, route) + info.Action = route.Action + relaycommon.StoreTaskRequest(c, info, route.Action, relaycommon.TaskSubmitReq{ + Model: info.OriginModelName, + Metadata: payload, + }) + + require.Equal(t, klingaiping.ModelCustomVoices, info.OriginModelName) + require.Equal(t, klingaiping.ActionVoicesCreate, info.Action) + require.Nil(t, info.LockedChannel) +} + +func TestConfigureKlingAipingTaskRelayInfoForcesChannelSelection(t *testing.T) { + c := newControllerJSONContext(t, "/v1/videos/text2video", `{"model_name":"Kling-V2.6","prompt":"prompt"}`) + payload, err := readJSONPayload(c) + require.NoError(t, err) + route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/videos/text2video", klingaiping.RouteKindSubmit) + require.True(t, ok) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} + + configureKlingAipingTaskRelayInfo(c, info, route, payload) + + require.NotNil(t, info.ChannelMeta) + require.Zero(t, info.ChannelMeta.ChannelType) + require.Equal(t, "Kling-V2.6", info.OriginModelName) + require.Equal(t, klingaiping.ActionText2Video, info.Action) + require.Nil(t, info.LockedChannel) +} + +func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T) { + c := newControllerJSONContext(t, "/v1/videos/text2video", `{}`) + c.Request.URL.Path = "/v1/videos/text2video" + require.Equal(t, constant.ChannelTypeKlingAiping, requiredTaskChannelTypeForRequest(c)) + + c = newControllerJSONContext(t, "/v1/videos/video_123/remix", `{}`) + c.Request.URL.Path = "/v1/videos/video_123/remix" + require.Zero(t, requiredTaskChannelTypeForRequest(c)) +} + +func TestKlingAipingTaskDataObjectUsesPublicTaskIDAndWatermarkURL(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusSuccess, + CreatedAt: 100, + UpdatedAt: 200, + Data: []byte(`{ + "code":0, + "aiping_id":"internal", + "data":{ + "task_id":"899333358055493641", + "task_status":"succeed", + "task_result":{"videos":[{"id":"v1","url":"https://example.com/video.mp4","duration":"5.041"}]} + } + }`), + } + + data := taskDataObject(task) + require.Equal(t, "task_public", data["task_id"]) + taskResult := data["task_result"].(map[string]any) + videos := taskResult["videos"].([]any) + require.Equal(t, "", videos[0].(map[string]any)["watermark_url"]) +} + +func TestProxyBodyBytesMapsElementDeleteIDToElementID(t *testing.T) { + c := newControllerJSONContext(t, "/v1/elements/delete", `{"id":"elem_1"}`) + route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/elements/delete", klingaiping.RouteKindProxy) + require.True(t, ok) + + data, err := proxyBodyBytes(c, route) + + require.NoError(t, err) + var payload map[string]any + require.NoError(t, common.Unmarshal(data, &payload)) + require.Equal(t, "elem_1", payload["element_id"]) + require.NotContains(t, payload, "id") +} + +func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) { + service.InitHttpClient() + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`)) + })) + defer server.Close() + + c := newControllerJSONContext(t, "/v1/elements/custom", ``) + common.SetContextKey(c, constant.ContextKeyChannelKey, "selected-key") + route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/elements/custom", klingaiping.RouteKindProxy) + require.True(t, ok) + channel := &model.Channel{ + Key: "raw-channel-key", + BaseURL: common.GetPointer(server.URL), + } + + resp, err := doKlingAipingProxyRequest(c, route, channel) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, "Bearer selected-key", gotAuth) +} + +func TestCopyProxyResponseNormalizesMsgError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(common.RequestIdKey, "req-test") + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":401,"msg":"unauthorized","data":null}`)), + } + + copyProxyResponse(c, resp) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), `"message":"unauthorized"`) + require.NotContains(t, w.Body.String(), `"msg"`) + require.Contains(t, w.Body.String(), `"request_id":"req-test"`) +} + +func TestCopyProxyResponseNormalizesPlainTextError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(common.RequestIdKey, "req-test") + resp := &http.Response{ + StatusCode: http.StatusMethodNotAllowed, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader("Method Not Allowed")), + } + + copyProxyResponse(c, resp) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + require.Contains(t, w.Body.String(), `"message":"Method Not Allowed"`) + require.Contains(t, w.Body.String(), `"request_id":"req-test"`) +} + +func TestCopyProxyResponseNormalizesDetailMessageError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(common.RequestIdKey, "req-test") + resp := &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"detail":{"message":"not found","error_type":"not_found"},"aiping_id":"internal"}`)), + } + + copyProxyResponse(c, resp) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Contains(t, w.Body.String(), `"message":"not found"`) + require.NotContains(t, w.Body.String(), `map[`) + require.Contains(t, w.Body.String(), `"request_id":"req-test"`) +} + +func TestNormalizeKlingAipingTaskErrorMessageExtractsUpstreamJSONMessage(t *testing.T) { + taskErr := &dto.TaskError{ + Code: "fail_to_fetch_task", + Message: `{"code":400,"message":"ERROR: image download failed","request_id":"upstream"}`, + StatusCode: http.StatusBadRequest, + } + + normalizeKlingAipingTaskError(taskErr) + + require.Equal(t, "ERROR: image download failed", taskErr.Message) +} + +func TestParseKlingAipingPageBoundaries(t *testing.T) { + c := newControllerJSONContext(t, "/v1/videos/text2video?pageNum=1001&pageSize=30", `{}`) + c.Request.URL.RawQuery = "pageNum=1001&pageSize=30" + _, _, err := parseKlingAipingPage(c) + require.ErrorContains(t, err, "pageNum") + + c = newControllerJSONContext(t, "/v1/videos/text2video?pageNum=1&pageSize=501", `{}`) + c.Request.URL.RawQuery = "pageNum=1&pageSize=501" + _, _, err = parseKlingAipingPage(c) + require.ErrorContains(t, err, "pageSize") +} diff --git a/controller/relay.go b/controller/relay.go index 48734d2..27e0eb7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -361,6 +361,9 @@ func requiredTaskChannelTypeForRequest(c *gin.Context) int { if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { return constant.ChannelTypeDoubaoVideoCompatibleAiping } + if isKlingAipingNativePath(c.Request.URL.Path) { + return constant.ChannelTypeKlingAiping + } return 0 } @@ -554,7 +557,9 @@ func preloadTaskPricingConfig(c *gin.Context, info *relaycommon.RelayInfo) *dto. if err := common.Unmarshal(body, &payload); err != nil { return service.TaskErrorWrapperLocal(err, "invalid_json", http.StatusBadRequest) } - if raw, ok := payload["model"].(string); ok && strings.TrimSpace(raw) != "" { + if raw, ok := payload["model_name"].(string); ok && strings.TrimSpace(raw) != "" { + modelName = strings.TrimSpace(raw) + } else if raw, ok := payload["model"].(string); ok && strings.TrimSpace(raw) != "" { modelName = strings.TrimSpace(raw) } if raw, ok := payload["action"].(string); ok && strings.TrimSpace(raw) != "" { @@ -750,9 +755,34 @@ func respondTaskError(c *gin.Context, taskErr *dto.TaskError) { if taskErr.StatusCode == http.StatusTooManyRequests { taskErr.Message = "当前分组上游负载已饱和,请稍后再试" } + if isKlingAipingNativePath(c.Request.URL.Path) { + normalizeKlingAipingTaskError(taskErr) + } c.JSON(taskErr.StatusCode, taskErr) } +func isKlingAipingNativePath(path string) bool { + return pathMatchesAnyKlingAipingNativePrefix(path, + "/v1/videos/text2video", + "/v1/videos/image2video", + "/v1/videos/motion-control", + "/v1/videos/omni-video", + "/v1/videos/multi-image2video", + "/v1/videos/multi-elements", + "/v1/kling/general/advanced-custom-elements", + "/v1/kling/general/custom-voices", + ) +} + +func pathMatchesAnyKlingAipingNativePrefix(path string, prefixes ...string) bool { + for _, prefix := range prefixes { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + return false +} + func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool { if taskErr == nil { return false diff --git a/relay/channel/task/kling/aiping/adaptor.go b/relay/channel/task/kling/aiping/adaptor.go new file mode 100644 index 0000000..69be913 --- /dev/null +++ b/relay/channel/task/kling/aiping/adaptor.go @@ -0,0 +1,341 @@ +package aiping + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` + AipingID string `json:"aiping_id"` + Data struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskInfo any `json:"task_info,omitempty"` + TaskResult struct { + Videos []struct { + ID string `json:"id"` + URL string `json:"url"` + WatermarkURL string `json:"watermark_url,omitempty"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } `json:"data"` +} + +type TaskAdaptor struct { + taskcommon.BaseBilling + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.apiKey = info.ApiKey + a.baseURL = strings.TrimRight(info.ChannelBaseUrl, "/") +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapperLocal(fmt.Errorf("task request not prepared"), "invalid_request", http.StatusBadRequest) + } + modelName := resolveRequestModelName(req, info) + if isOmniModel(modelName) && (info.Action == ActionText2Video || info.Action == ActionImage2Video) { + return service.TaskErrorWrapperLocal( + fmt.Errorf("model %s must use /v1/videos/omni-video", modelName), + "invalid_model_route", + http.StatusUnprocessableEntity, + ) + } + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + route, ok := FindRouteByAction(info.Action) + if !ok { + return "", fmt.Errorf("unsupported kling aiping action: %s", info.Action) + } + return a.baseURL + route.UpstreamPath, nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, err + } + body := cloneMap(req.Metadata) + normalizeRequestBody(body, info) + data, err := common.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var parsed responsePayload + if err := common.Unmarshal(responseBody, &parsed); err != nil { + return "", nil, service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + } + if parsed.Code != 0 { + msg := parsed.Message + if msg == "" { + msg = "upstream task failed" + } + return "", nil, service.TaskErrorWrapper(fmt.Errorf("%s", msg), "task_failed", http.StatusBadRequest) + } + upstreamTaskID := parsed.Data.TaskID + if strings.TrimSpace(upstreamTaskID) == "" { + return "", nil, service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + } + + clientPayload := map[string]any{} + if err := common.Unmarshal(responseBody, &clientPayload); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + sanitizeNativeTaskPayload(clientPayload, info.PublicTaskID) + c.JSON(http.StatusOK, clientPayload) + return upstreamTaskID, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { + taskID, _ := body["task_id"].(string) + if strings.TrimSpace(taskID) == "" { + taskID, _ = body["upstream_task_id"].(string) + } + action, _ := body["action"].(string) + if strings.TrimSpace(taskID) == "" { + return nil, fmt.Errorf("invalid task_id") + } + route, ok := FindFetchRouteByAction(action) + if !ok { + return nil, fmt.Errorf("unsupported kling aiping action: %s", action) + } + url := strings.TrimRight(baseURL, "/") + strings.Replace(route.UpstreamPath, ":task_id", taskID, 1) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var parsed responsePayload + if err := common.Unmarshal(respBody, &parsed); err != nil { + return nil, errors.Wrap(err, "unmarshal kling aiping task data failed") + } + info := &relaycommon.TaskInfo{ + Code: parsed.Code, + TaskID: parsed.Data.TaskID, + Reason: parsed.Data.TaskStatusMsg, + } + switch parsed.Data.TaskStatus { + case "submitted": + info.Status = model.TaskStatusSubmitted + info.Progress = taskcommon.ProgressSubmitted + case "processing": + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + case "succeed": + info.Status = model.TaskStatusSuccess + info.Progress = taskcommon.ProgressComplete + case "failed": + info.Status = model.TaskStatusFailure + info.Progress = taskcommon.ProgressComplete + default: + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + } + if len(parsed.Data.TaskResult.Videos) > 0 { + info.Url = parsed.Data.TaskResult.Videos[0].URL + } + return info, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return Models() +} + +func (a *TaskAdaptor) GetChannelName() string { + return "kling-aiping" +} + +func normalizeRequestBody(body map[string]any, info *relaycommon.RelayInfo) { + delete(body, "action_control") + delete(body, "uid") + delete(body, "create_at") + delete(body, "_standard_model") + + modelName := stringValue(body["model_name"]) + if modelName == "" { + modelName = stringValue(body["model"]) + } + if info != nil && info.IsModelMapped && info.UpstreamModelName != "" { + modelName = info.UpstreamModelName + } + if modelName != "" { + body["model_name"] = modelName + } + delete(body, "model") + + duration := body["duration"] + if duration == nil { + duration = body["seconds"] + } + if duration != nil { + body["duration"] = fmt.Sprint(duration) + } + delete(body, "seconds") + + if imageList, ok := body["reference_images"]; ok { + if _, exists := body["image_list"]; !exists { + body["image_list"] = imageList + } + delete(body, "reference_images") + } + if imageList, ok := normalizeImageList(body["image_list"]); ok { + body["image_list"] = imageList + } + + if body["watermark_info"] == nil { + return + } +} + +func normalizeImageList(v any) ([]map[string]any, bool) { + items, ok := v.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + switch val := item.(type) { + case string: + out = append(out, map[string]any{"image": val}) + case map[string]any: + image := firstString(val, "image", "image_url", "url", "base64") + if image == "" { + out = append(out, val) + continue + } + out = append(out, map[string]any{"image": image}) + default: + out = append(out, map[string]any{"image": fmt.Sprint(val)}) + } + } + return out, true +} + +func sanitizeNativeTaskPayload(payload map[string]any, publicTaskID string) { + delete(payload, "aiping_id") + data, _ := payload["data"].(map[string]any) + if data == nil { + return + } + data["task_id"] = publicTaskID + ensureWatermarkURL(data) + if _, ok := data["created_at"]; !ok { + data["created_at"] = time.Now().UnixMilli() + } + if _, ok := data["updated_at"]; !ok { + data["updated_at"] = data["created_at"] + } +} + +func ensureWatermarkURL(data map[string]any) { + taskResult, _ := data["task_result"].(map[string]any) + if taskResult == nil { + return + } + videos, _ := taskResult["videos"].([]any) + for _, videoAny := range videos { + video, _ := videoAny.(map[string]any) + if video == nil { + continue + } + if _, ok := video["watermark_url"]; !ok { + video["watermark_url"] = "" + } + } +} + +func cloneMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func stringValue(v any) string { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func firstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if s := stringValue(m[key]); s != "" { + return s + } + } + return "" +} + +func resolveRequestModelName(req relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) string { + if info != nil && info.ChannelMeta != nil && info.IsModelMapped && info.UpstreamModelName != "" { + return strings.TrimSpace(info.UpstreamModelName) + } + if modelName := firstString(req.Metadata, "model_name", "model"); modelName != "" { + return modelName + } + return strings.TrimSpace(req.Model) +} + +func isOmniModel(modelName string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelName)) + return normalized == "kling-video-o1" || normalized == "kling-v3-omni" +} diff --git a/relay/channel/task/kling/aiping/adaptor_test.go b/relay/channel/task/kling/aiping/adaptor_test.go new file mode 100644 index 0000000..34f9941 --- /dev/null +++ b/relay/channel/task/kling/aiping/adaptor_test.go @@ -0,0 +1,168 @@ +package aiping + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestRoutesUseOfficialExternalAndAipingUpstreamPaths(t *testing.T) { + route, ok := FindRoute(http.MethodPost, "/v1/videos/text2video", RouteKindSubmit) + require.True(t, ok) + require.Equal(t, ActionText2Video, route.Action) + require.Equal(t, "/v1/videos/kling/text2video", route.UpstreamPath) + + voice, ok := FindRoute(http.MethodPost, "/v1/kling/general/custom-voices", RouteKindSubmit) + require.True(t, ok) + require.Equal(t, "/v1/kling/general/custom-voices", voice.UpstreamPath) + + presets, ok := FindRoute(http.MethodGet, "/v1/kling/general/presets-voices", RouteKindProxy) + require.True(t, ok) + require.Equal(t, "/v1/kling/general/presets-voices", presets.UpstreamPath) + + deleteVoices, ok := FindRoute(http.MethodPost, "/v1/kling/general/delete-voices", RouteKindProxy) + require.True(t, ok) + require.Equal(t, ActionDeleteVoices, deleteVoices.Action) + + deleteElements, ok := FindRoute(http.MethodPost, "/v1/kling/general/delete-elements", RouteKindProxy) + require.True(t, ok) + require.Equal(t, ActionDeleteElements, deleteElements.Action) +} + +func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) { + adaptor := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn/api"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + Action: ActionText2Video, + }, + } + adaptor.Init(info) + + url, err := adaptor.BuildRequestURL(info) + require.NoError(t, err) + require.Equal(t, "https://aiping.cn/api/v1/videos/kling/text2video", url) +} + +func TestFetchTaskUsesSingleTaskPath(t *testing.T) { + service.InitHttpClient() + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"task_id":"upstream-task","task_status":"submitted"}}`)) + })) + defer server.Close() + + resp, err := (&TaskAdaptor{}).FetchTask(server.URL, "key", map[string]any{ + "task_id": "upstream-task", + "action": ActionText2Video, + }, "") + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, "/v1/videos/kling/text2video/upstream-task", gotPath) +} + +func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + Action: ActionMultiImage2Video, + }, + OriginModelName: "Kling-V2.6", + } + relaycommon.StoreTaskRequest(c, info, ActionMultiImage2Video, relaycommon.TaskSubmitReq{ + Model: "Kling-V2.6", + Metadata: map[string]any{ + "model": "Kling-V1.6", + "model_name": "Kling-V2.6", + "seconds": 3, + "duration": 5, + "action_control": map[string]any{"type": "continuous"}, + "uid": "user-1", + "create_at": float64(1750000000000), + "_standard_model": "Kling-V-2-6", + "image_list": []any{ + "https://example.com/1.png", + map[string]any{"image_url": "https://example.com/2.png"}, + map[string]any{"url": "https://example.com/3.png"}, + map[string]any{"base64": "abc"}, + }, + }, + }) + + body, err := (&TaskAdaptor{}).BuildRequestBody(c, info) + require.NoError(t, err) + data, err := io.ReadAll(body) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, common.Unmarshal(data, &got)) + require.Equal(t, "Kling-V2.6", got["model_name"]) + require.NotContains(t, got, "model") + require.Equal(t, "5", got["duration"]) + require.NotContains(t, got, "seconds") + require.NotContains(t, got, "action_control") + require.NotContains(t, got, "uid") + require.NotContains(t, got, "create_at") + require.NotContains(t, got, "_standard_model") + require.Equal(t, []any{ + map[string]any{"image": "https://example.com/1.png"}, + map[string]any{"image": "https://example.com/2.png"}, + map[string]any{"image": "https://example.com/3.png"}, + map[string]any{"image": "abc"}, + }, got["image_list"]) +} + +func TestValidateRequestRejectsOmniModelOnTextOrImageRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + Action: ActionText2Video, + }, + } + relaycommon.StoreTaskRequest(c, info, ActionText2Video, relaycommon.TaskSubmitReq{ + Model: "kling-v3-omni", + Metadata: map[string]any{ + "model_name": "kling-v3-omni", + }, + }) + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + + require.NotNil(t, taskErr) + require.Equal(t, http.StatusUnprocessableEntity, taskErr.StatusCode) + require.Contains(t, taskErr.Message, "/v1/videos/omni-video") +} + +func TestSanitizeNativeTaskPayloadUsesPublicTaskIDAndAddsWatermarkURL(t *testing.T) { + payload := map[string]any{ + "aiping_id": "internal", + "data": map[string]any{ + "task_id": "899333358055493641", + "task_result": map[string]any{ + "videos": []any{ + map[string]any{"id": "v1", "url": "https://example.com/video.mp4"}, + }, + }, + }, + } + + sanitizeNativeTaskPayload(payload, "task_public") + require.NotContains(t, payload, "aiping_id") + data := payload["data"].(map[string]any) + require.Equal(t, "task_public", data["task_id"]) + videos := data["task_result"].(map[string]any)["videos"].([]any) + require.Equal(t, "", videos[0].(map[string]any)["watermark_url"]) +} diff --git a/relay/channel/task/kling/aiping/routes.go b/relay/channel/task/kling/aiping/routes.go new file mode 100644 index 0000000..362c77b --- /dev/null +++ b/relay/channel/task/kling/aiping/routes.go @@ -0,0 +1,124 @@ +package aiping + +import "net/http" + +const ( + ActionText2Video = "kling-text2video" + ActionImage2Video = "kling-image2video" + ActionMotionControl = "kling-motion-control" + ActionOmniVideo = "kling-omni-video" + ActionMultiImage2Video = "kling-multi-image2video" + ActionMultiElements = "kling-multi-elements" + ActionAdvancedElements = "kling-advanced-elements-create" + ActionVoicesCreate = "kling-voices-create" + ActionDeleteElements = "kling-delete-elements" + ActionDeleteVoices = "kling-voices-delete" +) + +const ( + RouteKindSubmit = "submit" + RouteKindFetch = "fetch" + RouteKindList = "list" + RouteKindProxy = "proxy" +) + +const ( + ModelKlingElements = "kling-elements" + ModelKlingAdvancedElements = "kling-advanced-elements" + ModelCustomVoices = "custom-voices" +) + +type Route struct { + Method string + PathPattern string + Action string + UpstreamPath string + Kind string + BillingModel string +} + +var Routes = []Route{ + {http.MethodPost, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindSubmit, ""}, + {http.MethodGet, "/v1/videos/text2video/:task_id", ActionText2Video, "/v1/videos/kling/text2video/:task_id", RouteKindFetch, ""}, + {http.MethodGet, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindList, ""}, + {http.MethodPost, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindSubmit, ""}, + {http.MethodGet, "/v1/videos/image2video/:task_id", ActionImage2Video, "/v1/videos/kling/image2video/:task_id", RouteKindFetch, ""}, + {http.MethodGet, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindList, ""}, + {http.MethodPost, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindSubmit, "kling-v2-6"}, + {http.MethodGet, "/v1/videos/motion-control/:task_id", ActionMotionControl, "/v1/videos/kling/motion-control/:task_id", RouteKindFetch, "kling-v2-6"}, + {http.MethodGet, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindList, "kling-v2-6"}, + {http.MethodPost, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindSubmit, ""}, + {http.MethodGet, "/v1/videos/omni-video/:task_id", ActionOmniVideo, "/v1/videos/kling/omni-video/:task_id", RouteKindFetch, ""}, + {http.MethodGet, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindList, ""}, + {http.MethodPost, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindSubmit, ""}, + {http.MethodGet, "/v1/videos/multi-image2video/:task_id", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video/:task_id", RouteKindFetch, ""}, + {http.MethodGet, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindList, ""}, + {http.MethodPost, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindSubmit, "kling-v1-6"}, + {http.MethodGet, "/v1/videos/multi-elements/:task_id", ActionMultiElements, "/v1/videos/kling/multi-elements/:task_id", RouteKindFetch, "kling-v1-6"}, + {http.MethodGet, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindList, "kling-v1-6"}, + + {http.MethodPost, "/v1/videos/multi-elements/init-selection", "kling-multi-elements-init-selection", "/v1/videos/kling/multi-elements/init-selection", RouteKindProxy, ModelKlingElements}, + {http.MethodPost, "/v1/videos/multi-elements/add-selection", "kling-multi-elements-add-selection", "/v1/videos/kling/multi-elements/add-selection", RouteKindProxy, ModelKlingElements}, + {http.MethodPost, "/v1/videos/multi-elements/delete-selection", "kling-multi-elements-delete-selection", "/v1/videos/kling/multi-elements/delete-selection", RouteKindProxy, ModelKlingElements}, + {http.MethodPost, "/v1/videos/multi-elements/clear-selection", "kling-multi-elements-clear-selection", "/v1/videos/kling/multi-elements/clear-selection", RouteKindProxy, ModelKlingElements}, + {http.MethodPost, "/v1/videos/multi-elements/preview-selection", "kling-multi-elements-preview-selection", "/v1/videos/kling/multi-elements/preview-selection", RouteKindProxy, ModelKlingElements}, + + {http.MethodPost, "/v1/elements/custom", "kling-elements-custom-create", "/v1/elements/custom", RouteKindProxy, ModelKlingElements}, + {http.MethodGet, "/v1/elements/custom", "kling-elements-custom-list", "/v1/elements/custom", RouteKindProxy, ModelKlingElements}, + {http.MethodGet, "/v1/elements/:id", "kling-elements-get", "/v1/elements/:id", RouteKindProxy, ModelKlingElements}, + {http.MethodGet, "/v1/elements/presets", "kling-elements-presets", "/v1/elements/presets", RouteKindProxy, ModelKlingElements}, + {http.MethodPost, "/v1/elements/delete", "kling-elements-delete", "/v1/elements/delete", RouteKindProxy, ModelKlingElements}, + + {http.MethodPost, "/v1/kling/general/advanced-custom-elements", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements", RouteKindSubmit, ModelKlingAdvancedElements}, + {http.MethodGet, "/v1/kling/general/advanced-custom-elements/:task_id", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements/:task_id", RouteKindFetch, ModelKlingAdvancedElements}, + {http.MethodGet, "/v1/kling/general/advanced-custom-elements", "kling-advanced-elements-list", "/v1/kling/general/advanced-custom-elements", RouteKindProxy, ModelKlingAdvancedElements}, + {http.MethodGet, "/v1/kling/general/advanced-presets-elements", "kling-advanced-elements-presets", "/v1/kling/general/advanced-presets-elements", RouteKindProxy, ModelKlingAdvancedElements}, + {http.MethodPost, "/v1/kling/general/delete-elements", ActionDeleteElements, "/v1/kling/general/delete-elements", RouteKindProxy, ModelKlingAdvancedElements}, + + {http.MethodPost, "/v1/kling/general/custom-voices", ActionVoicesCreate, "/v1/kling/general/custom-voices", RouteKindSubmit, ModelCustomVoices}, + {http.MethodGet, "/v1/kling/general/custom-voices/:task_id", ActionVoicesCreate, "/v1/kling/general/custom-voices/:task_id", RouteKindFetch, ModelCustomVoices}, + {http.MethodGet, "/v1/kling/general/custom-voices", "kling-voices-list", "/v1/kling/general/custom-voices", RouteKindProxy, ModelCustomVoices}, + {http.MethodGet, "/v1/kling/general/presets-voices", "kling-voices-presets", "/v1/kling/general/presets-voices", RouteKindProxy, ModelCustomVoices}, + {http.MethodPost, "/v1/kling/general/delete-voices", ActionDeleteVoices, "/v1/kling/general/delete-voices", RouteKindProxy, ModelCustomVoices}, +} + +func FindRoute(method, pathPattern, kind string) (Route, bool) { + for _, route := range Routes { + if route.Method == method && route.PathPattern == pathPattern && route.Kind == kind { + return route, true + } + } + return Route{}, false +} + +func FindRouteByAction(action string) (Route, bool) { + for _, route := range Routes { + if route.Action == action && route.Kind == RouteKindSubmit { + return route, true + } + } + return Route{}, false +} + +func FindFetchRouteByAction(action string) (Route, bool) { + for _, route := range Routes { + if route.Action == action && route.Kind == RouteKindFetch { + return route, true + } + } + return Route{}, false +} + +func Models() []string { + return []string{ + "kling-v1", + "kling-v1-6", + "kling-v2-6", + "kling-v3", + "kling-video-o1", + "kling-v3-omni", + ModelKlingElements, + ModelKlingAdvancedElements, + ModelCustomVoices, + } +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 06d0038..adeb06f 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -37,6 +37,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/task/hailuo" taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" "github.com/QuantumNous/new-api/relay/channel/task/kling" + klingaiping "github.com/QuantumNous/new-api/relay/channel/task/kling/aiping" tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" "github.com/QuantumNous/new-api/relay/channel/task/suno" taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" @@ -156,6 +157,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskdoubao.TaskAdaptor{} case constant.ChannelTypeDoubaoVideoCompatibleAiping: return &taskdoubaoaiping.TaskAdaptor{} + case constant.ChannelTypeKlingAiping: + return &klingaiping.TaskAdaptor{} case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: return &tasksora.TaskAdaptor{} case constant.ChannelTypeGemini: diff --git a/router/video-router.go b/router/video-router.go index dd31771..11debfe 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -22,6 +22,52 @@ func SetVideoRouter(router *gin.Engine) { videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch) videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask) } + klingAipingRouter := router.Group("/v1") + klingAipingRouter.Use(middleware.TokenAuth()) + { + klingAipingRouter.POST("/videos/multi-elements/init-selection", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/videos/multi-elements/add-selection", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/videos/multi-elements/delete-selection", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/videos/multi-elements/clear-selection", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/videos/multi-elements/preview-selection", controller.KlingAipingNativeProxy) + + klingAipingRouter.POST("/videos/text2video", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/text2video/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/text2video", controller.KlingAipingNativeTaskList) + klingAipingRouter.POST("/videos/image2video", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/image2video/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/image2video", controller.KlingAipingNativeTaskList) + klingAipingRouter.POST("/videos/motion-control", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/motion-control/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/motion-control", controller.KlingAipingNativeTaskList) + klingAipingRouter.POST("/videos/omni-video", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/omni-video/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/omni-video", controller.KlingAipingNativeTaskList) + klingAipingRouter.POST("/videos/multi-image2video", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/multi-image2video/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/multi-image2video", controller.KlingAipingNativeTaskList) + klingAipingRouter.POST("/videos/multi-elements", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/multi-elements/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/multi-elements", controller.KlingAipingNativeTaskList) + + klingAipingRouter.POST("/elements/custom", controller.KlingAipingNativeProxy) + klingAipingRouter.GET("/elements/custom", controller.KlingAipingNativeProxy) + klingAipingRouter.GET("/elements/presets", controller.KlingAipingNativeProxy) + klingAipingRouter.GET("/elements/:id", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/elements/delete", controller.KlingAipingNativeProxy) + + klingAipingRouter.POST("/kling/general/advanced-custom-elements", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/kling/general/advanced-custom-elements/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/kling/general/advanced-custom-elements", controller.KlingAipingNativeProxy) + klingAipingRouter.GET("/kling/general/advanced-presets-elements", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/kling/general/delete-elements", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/kling/general/custom-voices", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/kling/general/custom-voices/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/kling/general/custom-voices", controller.KlingAipingNativeProxy) + klingAipingRouter.GET("/kling/general/presets-voices", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/kling/general/delete-voices", controller.KlingAipingNativeProxy) + } + // openai compatible API video routes // docs: https://platform.openai.com/docs/api-reference/videos/create { diff --git a/router/video_router_test.go b/router/video_router_test.go new file mode 100644 index 0000000..20e8c78 --- /dev/null +++ b/router/video_router_test.go @@ -0,0 +1,41 @@ +package router + +import ( + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestKlingAipingNativeRoutesDoNotFallThroughToOpenAIVideoRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + SetVideoRouter(r) + + requireHandlerContains(t, r, http.MethodPost, "/v1/videos/text2video", "KlingAipingNativeTaskSubmit") + requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video/:task_id", "KlingAipingNativeTaskFetch") + requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video", "KlingAipingNativeTaskList") + requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements/init-selection", "KlingAipingNativeProxy") + requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements", "KlingAipingNativeTaskSubmit") + requireHandlerContains(t, r, http.MethodGet, "/v1/elements/presets", "KlingAipingNativeProxy") + requireHandlerContains(t, r, http.MethodGet, "/v1/elements/:id", "KlingAipingNativeProxy") + requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-elements", "KlingAipingNativeProxy") + requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-voices", "KlingAipingNativeProxy") + + requireHandlerContains(t, r, http.MethodPost, "/v1/videos", "RelayTask") + requireHandlerContains(t, r, http.MethodGet, "/v1/videos/:task_id", "RelayTaskFetch") +} + +func requireHandlerContains(t *testing.T, r *gin.Engine, method, path, want string) { + t.Helper() + for _, route := range r.Routes() { + if route.Method != method || route.Path != path { + continue + } + require.Truef(t, strings.Contains(route.Handler, want), "handler for %s %s = %s, want containing %s", method, path, route.Handler, want) + return + } + t.Fatalf("route not found: %s %s", method, path) +} diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 265a94e..065392c 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -194,6 +194,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'DoubaoVideoCompatibleAiping', }, + { + value: 59, + color: 'green', + label: 'KlingAiping', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 118affa..bdfee36 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -395,6 +395,7 @@ export function getChannelIcon(channelType) { case 49: // Coze return ; case 50: // 可灵 Kling + case 59: // KlingAiping return ; case 51: // 即梦 Jimeng return ; diff --git a/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx b/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx new file mode 100644 index 0000000..f58b226 --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx @@ -0,0 +1,277 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Button, + Input, + Popconfirm, + SideSheet, + Space, + Table, + Tag, + TextArea, + Typography, +} from '@douyinfe/semi-ui'; +import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { + createDefaultDisplayPricingItems, + formatDisplayPricingJson, + normalizeDisplayPricingItems, + parseDisplayPricingJson, +} from './displayPricingConfig'; + +export default function DisplayPricing() { + const { t } = useTranslation(); + const [rules, setRules] = useState({}); + const [keyword, setKeyword] = useState(''); + const [editingModel, setEditingModel] = useState(null); + const [editingItems, setEditingItems] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = async () => { + setLoading(true); + try { + const res = await API.get('/api/option/model_display_pricing'); + if (!res.data?.success) { + showError(res.data?.message || t('加载展示价格失败')); + return; + } + setRules(res.data.data || {}); + } catch (error) { + showError(error.message || t('加载展示价格失败')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refresh(); + }, []); + + const data = useMemo( + () => + Object.entries(rules || {}) + .filter(([model]) => + model.toLowerCase().includes(keyword.toLowerCase()), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([model, items]) => { + const normalized = normalizeDisplayPricingItems(items); + return { + model, + count: normalized.length, + preview: normalized.slice(0, 3), + items: normalized, + }; + }), + [rules, keyword], + ); + + const openEditor = (model, items) => { + setEditingModel(model); + setEditingItems(items || createDefaultDisplayPricingItems()); + }; + + const remove = async (model) => { + try { + const res = await API.delete( + `/api/option/model_display_pricing/${encodeURIComponent(model)}`, + ); + if (!res.data?.success) { + showError(res.data?.message || t('删除展示价格失败')); + return; + } + showSuccess(t('展示价格已删除')); + await refresh(); + } catch (error) { + showError(error.message || t('删除展示价格失败')); + } + }; + + const columns = [ + { title: t('模型'), dataIndex: 'model' }, + { title: t('规格数量'), dataIndex: 'count', width: 110 }, + { + title: t('规格预览'), + dataIndex: 'preview', + render: (items) => ( + + {(items || []).map((item) => ( + + {item.specification}: {item.price} / {item.unit} + + ))} + + ), + }, + { + title: t('操作'), + width: 120, + render: (_, row) => ( + + + + + + + + setEditingModel(null)} + onSaved={refresh} + /> + + ); +} + +function DisplayPricingEditor({ visible, modelName, items, onClose, onSaved }) { + const { t } = useTranslation(); + const [name, setName] = useState(modelName || ''); + const [jsonText, setJsonText] = useState(formatDisplayPricingJson(items)); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!visible) return; + setName(modelName || ''); + setJsonText(formatDisplayPricingJson(items)); + }, [visible, modelName, items]); + + const save = async () => { + if (!name.trim()) { + showError(t('模型名称不能为空')); + return; + } + const parsed = parseDisplayPricingJson(jsonText); + if (!parsed.ok) { + showError(`${t('JSON 格式错误')}: ${parsed.error}`); + return; + } + + setSaving(true); + try { + const res = await API.put( + `/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`, + parsed.items, + ); + if (!res.data?.success) { + showError(res.data?.message || t('保存展示价格失败')); + return; + } + showSuccess(t('展示价格已保存')); + await onSaved?.(); + onClose?.(); + } catch (error) { + showError(error.message || t('保存展示价格失败')); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > + + + + {t( + '填写数组 JSON。每一项包含 specification、official_supplier_tip、price、unit、sort_order、discount_rate;price 使用 USD 基准价。', + )} + +