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) => (
+
+ }
+ theme='borderless'
+ data-testid={`display-pricing-edit-${row.model}`}
+ onClick={() => openEditor(row.model, row.items)}
+ />
+ remove(row.model)}
+ >
+ }
+ type='danger'
+ theme='borderless'
+ />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+ {t('展示价格')}
+
+
+ {t(
+ '仅用于模型广场展示和用户估算,不参与真实扣费。price 按 USD 保存,用户端会按站点货币设置换算展示。',
+ )}
+
+
+
+ }
+ onClick={refresh}
+ loading={loading}
+ >
+ {t('刷新')}
+
+ }
+ type='primary'
+ data-testid='display-pricing-add-model'
+ onClick={() => openEditor('', createDefaultDisplayPricingItems())}
+ >
+ {t('添加展示价格')}
+
+
+
+
+
+ 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 基准价。',
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js b/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js
new file mode 100644
index 0000000..5d1c17c
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.js
@@ -0,0 +1,61 @@
+export const createDefaultDisplayPricingItems = () => [
+ {
+ specification: '768p-6s',
+ official_supplier_tip: '文生视频,图生视频,768P 6s',
+ price: 0.33333,
+ unit: 'second',
+ sort_order: 1,
+ discount_rate: 1,
+ },
+ {
+ specification: '768p-10s',
+ official_supplier_tip: '文生视频,图生视频,768P 10s',
+ price: 0.4,
+ unit: 'second',
+ sort_order: 2,
+ discount_rate: 1,
+ },
+ {
+ specification: '1080p',
+ official_supplier_tip: '文生视频,图生视频,1080P',
+ price: 0.58333,
+ unit: 'second',
+ sort_order: 3,
+ discount_rate: 1,
+ },
+];
+
+export const formatDisplayPricingJson = (items) =>
+ JSON.stringify(Array.isArray(items) ? items : [], null, 2);
+
+export const parseDisplayPricingJson = (text) => {
+ let parsed;
+ try {
+ parsed = JSON.parse(text || '[]');
+ } catch (error) {
+ return { ok: false, error: error.message, items: [] };
+ }
+
+ if (!Array.isArray(parsed)) {
+ return {
+ ok: false,
+ error: 'display pricing JSON must be an array',
+ items: [],
+ };
+ }
+
+ return {
+ ok: true,
+ items: normalizeDisplayPricingItems(parsed),
+ };
+};
+
+export const normalizeDisplayPricingItems = (items) =>
+ [...(items || [])].sort((left, right) => {
+ const leftOrder = Number(left?.sort_order ?? Number.MAX_SAFE_INTEGER);
+ const rightOrder = Number(right?.sort_order ?? Number.MAX_SAFE_INTEGER);
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
+ return String(left?.specification || '').localeCompare(
+ String(right?.specification || ''),
+ );
+ });
diff --git a/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js b/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js
new file mode 100644
index 0000000..b3a230c
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/displayPricingConfig.test.js
@@ -0,0 +1,49 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ createDefaultDisplayPricingItems,
+ formatDisplayPricingJson,
+ parseDisplayPricingJson,
+} from './displayPricingConfig';
+
+describe('displayPricingConfig utilities', () => {
+ test('parses and sorts display pricing JSON by sort_order', () => {
+ const result = parseDisplayPricingJson(`[
+ {
+ "specification": "1080p",
+ "official_supplier_tip": "文生视频,图生视频,1080P",
+ "price": 0.58333,
+ "unit": "second",
+ "sort_order": 3,
+ "discount_rate": 1
+ },
+ {
+ "specification": "768p-6s",
+ "official_supplier_tip": "文生视频,图生视频,768P 6s",
+ "price": 0.33333,
+ "unit": "second",
+ "sort_order": 1,
+ "discount_rate": 0.8
+ }
+ ]`);
+
+ expect(result.ok).toBe(true);
+ expect(result.items.map((item) => item.specification)).toEqual([
+ '768p-6s',
+ '1080p',
+ ]);
+ expect(result.items[0].price).toBe(0.33333);
+ expect(result.items[0].discount_rate).toBe(0.8);
+ });
+
+ test('rejects non-array JSON', () => {
+ const result = parseDisplayPricingJson('{"specification":"768p"}');
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('array');
+ });
+
+ test('formats default items as editable JSON array', () => {
+ const text = formatDisplayPricingJson(createDefaultDisplayPricingItems());
+ expect(text).toContain('"specification": "768p-6s"');
+ expect(text).toContain('"unit": "second"');
+ });
+});
diff --git a/web/src/pages/Setting/Operation/ModelPricing/index.jsx b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
index 3418c62..b6ae644 100644
--- a/web/src/pages/Setting/Operation/ModelPricing/index.jsx
+++ b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
@@ -1,8 +1,20 @@
import React, { useEffect, useMemo, useState } from 'react';
-import { Button, Card, Input, Popconfirm, Space, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import {
+ Button,
+ Card,
+ Input,
+ Popconfirm,
+ Space,
+ Table,
+ TabPane,
+ Tabs,
+ Tag,
+ 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 DisplayPricing from './DisplayPricing';
import Editor from './Editor';
export default function ModelPricing() {
@@ -16,14 +28,19 @@ export default function ModelPricing() {
const refresh = async () => {
setLoading(true);
try {
- const [rulesRes, optionsRes] = await Promise.all([API.get('/api/option/model_pricing'), API.get('/api/option/')]);
+ const [rulesRes, optionsRes] = await Promise.all([
+ API.get('/api/option/model_pricing'),
+ API.get('/api/option/'),
+ ]);
if (rulesRes.data?.success) {
setRules(rulesRes.data.data || {});
} else {
showError(rulesRes.data?.message || t('加载模型多维计费失败'));
}
if (optionsRes.data?.success) {
- const modelPrice = optionsRes.data.data?.find((item) => item.key === 'ModelPrice')?.value || '{}';
+ const modelPrice =
+ optionsRes.data.data?.find((item) => item.key === 'ModelPrice')
+ ?.value || '{}';
setLegacyPrices(parseJsonObject(modelPrice));
}
} catch (error) {
@@ -38,7 +55,10 @@ export default function ModelPricing() {
}, []);
const data = useMemo(() => {
- const names = new Set([...Object.keys(legacyPrices), ...Object.keys(rules)]);
+ const names = new Set([
+ ...Object.keys(legacyPrices),
+ ...Object.keys(rules),
+ ]);
return [...names]
.filter((name) => name.toLowerCase().includes(keyword.toLowerCase()))
.sort()
@@ -57,7 +77,9 @@ export default function ModelPricing() {
const remove = async (model) => {
try {
- const res = await API.delete(`/api/option/model_pricing/${encodeURIComponent(model)}`);
+ const res = await API.delete(
+ `/api/option/model_pricing/${encodeURIComponent(model)}`,
+ );
if (!res.data?.success) {
showError(res.data?.message || t('删除模型多维计费失败'));
return;
@@ -75,24 +97,44 @@ export default function ModelPricing() {
title: t('状态'),
dataIndex: 'configured',
width: 120,
- render: (configured) => {configured ? t('已配置') : t('Legacy 回退')},
+ render: (configured) => (
+
+ {configured ? t('已配置') : t('Legacy 回退')}
+
+ ),
},
{ title: t('维度'), dataIndex: 'dimensions', width: 90 },
{ title: t('行数'), dataIndex: 'rows', width: 90 },
{ title: t('计费单位'), dataIndex: 'billingUnit', width: 150 },
- { title: t('Legacy 价格'), dataIndex: 'legacyPrice', width: 140, render: (value) => value ?? '-' },
+ {
+ title: t('Legacy 价格'),
+ dataIndex: 'legacyPrice',
+ width: 140,
+ render: (value) => value ?? '-',
+ },
{
title: t('操作'),
width: 120,
render: (_, row) => (
- } theme='borderless' data-testid={`model-pricing-edit-${row.model}`} onClick={() => setEditingModel(row.model)} />
+ }
+ theme='borderless'
+ data-testid={`model-pricing-edit-${row.model}`}
+ onClick={() => setEditingModel(row.model)}
+ />
{row.configured && (
remove(row.model)}
>
- } type='danger' theme='borderless' />
+ }
+ type='danger'
+ theme='borderless'
+ />
)}
@@ -102,21 +144,52 @@ export default function ModelPricing() {
return (
-
-
- {t('模型多维计费')}
-
- } onClick={refresh} loading={loading}>
- {t('刷新')}
-
- } type='primary' data-testid='model-pricing-add-model' onClick={() => setEditingModel('')}>
- {t('添加模型')}
-
+
+
+
+
+
+ {t('模型多维计费')}
+
+
+ }
+ onClick={refresh}
+ loading={loading}
+ >
+ {t('刷新')}
+
+ }
+ type='primary'
+ data-testid='model-pricing-add-model'
+ onClick={() => setEditingModel('')}
+ >
+ {t('添加模型')}
+
+
+
+
+
-
-
-
-
+
+
+
+
+