- 新增 KlingAiping 渠道 (ChannelTypeKlingAiping = 59) - 新增 KlingAiping 原生路由 (text2video/image2video/omni-video 等) - 新增 KlingAiping adaptor (relay/channel/task/kling/aiping/) - 前端模型定价页面新增「真实计费 / 展示价格」Tab 切换 - 新增 DisplayPricing 组件,支持模型展示价格的增删改 - 新增 displayPricingConfig 工具函数及测试 - 修复 KlingAiping 路由路径匹配与 task 错误响应 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -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 { | |||
| @@ -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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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 | |||
| @@ -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" | |||
| } | |||
| @@ -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"]) | |||
| } | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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: | |||
| @@ -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 | |||
| { | |||
| @@ -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) | |||
| } | |||
| @@ -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; | |||
| @@ -395,6 +395,7 @@ export function getChannelIcon(channelType) { | |||
| case 49: // Coze | |||
| return <Coze size={iconSize} />; | |||
| case 50: // 可灵 Kling | |||
| case 59: // KlingAiping | |||
| return <Kling.Color size={iconSize} />; | |||
| case 51: // 即梦 Jimeng | |||
| return <Jimeng.Color size={iconSize} />; | |||
| @@ -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) => ( | |||
| <Space wrap> | |||
| {(items || []).map((item) => ( | |||
| <Tag key={item.specification}> | |||
| {item.specification}: {item.price} / {item.unit} | |||
| </Tag> | |||
| ))} | |||
| </Space> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 120, | |||
| render: (_, row) => ( | |||
| <Space> | |||
| <Button | |||
| icon={<Edit size={16} />} | |||
| theme='borderless' | |||
| data-testid={`display-pricing-edit-${row.model}`} | |||
| onClick={() => openEditor(row.model, row.items)} | |||
| /> | |||
| <Popconfirm | |||
| title={t('删除后用户端不再展示该模型的展示价格,是否继续?')} | |||
| onConfirm={() => remove(row.model)} | |||
| > | |||
| <Button | |||
| icon={<Trash2 size={16} />} | |||
| type='danger' | |||
| theme='borderless' | |||
| /> | |||
| </Popconfirm> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Space vertical align='start' spacing={2}> | |||
| <Typography.Title heading={5} style={{ margin: 0 }}> | |||
| {t('展示价格')} | |||
| </Typography.Title> | |||
| <Typography.Text type='tertiary'> | |||
| {t( | |||
| '仅用于模型广场展示和用户估算,不参与真实扣费。price 按 USD 保存,用户端会按站点货币设置换算展示。', | |||
| )} | |||
| </Typography.Text> | |||
| </Space> | |||
| <Space> | |||
| <Button | |||
| icon={<RefreshCw size={16} />} | |||
| onClick={refresh} | |||
| loading={loading} | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| type='primary' | |||
| data-testid='display-pricing-add-model' | |||
| onClick={() => openEditor('', createDefaultDisplayPricingItems())} | |||
| > | |||
| {t('添加展示价格')} | |||
| </Button> | |||
| </Space> | |||
| </Space> | |||
| <Input | |||
| placeholder={t('搜索模型')} | |||
| value={keyword} | |||
| onChange={setKeyword} | |||
| style={{ width: 280 }} | |||
| data-testid='display-pricing-search' | |||
| /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={data} | |||
| rowKey='model' | |||
| pagination={{ pageSize: 10 }} | |||
| loading={loading} | |||
| data-testid='display-pricing-table' | |||
| /> | |||
| <DisplayPricingEditor | |||
| visible={editingModel !== null} | |||
| modelName={editingModel || ''} | |||
| items={editingItems} | |||
| onClose={() => setEditingModel(null)} | |||
| onSaved={refresh} | |||
| /> | |||
| </Space> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <SideSheet | |||
| visible={visible} | |||
| title={modelName ? t('编辑展示价格') : t('添加展示价格')} | |||
| width='min(960px, calc(100vw - 48px))' | |||
| onCancel={onClose} | |||
| data-testid='display-pricing-editor' | |||
| footer={ | |||
| <Space> | |||
| <Button onClick={onClose}>{t('取消')}</Button> | |||
| <Button | |||
| type='primary' | |||
| onClick={save} | |||
| loading={saving} | |||
| data-testid='display-pricing-save' | |||
| > | |||
| {t('保存')} | |||
| </Button> | |||
| </Space> | |||
| } | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Input | |||
| prefix={t('模型')} | |||
| value={name} | |||
| onChange={setName} | |||
| style={{ width: 320 }} | |||
| disabled={Boolean(modelName)} | |||
| data-testid='display-pricing-model-name' | |||
| /> | |||
| <Typography.Text type='tertiary'> | |||
| {t( | |||
| '填写数组 JSON。每一项包含 specification、official_supplier_tip、price、unit、sort_order、discount_rate;price 使用 USD 基准价。', | |||
| )} | |||
| </Typography.Text> | |||
| <TextArea | |||
| value={jsonText} | |||
| onChange={setJsonText} | |||
| autosize={{ minRows: 18, maxRows: 30 }} | |||
| data-testid='display-pricing-json-editor' | |||
| /> | |||
| </Space> | |||
| </SideSheet> | |||
| ); | |||
| } | |||
| @@ -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 || ''), | |||
| ); | |||
| }); | |||
| @@ -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"'); | |||
| }); | |||
| }); | |||
| @@ -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) => <Tag color={configured ? 'green' : 'grey'}>{configured ? t('已配置') : t('Legacy 回退')}</Tag>, | |||
| render: (configured) => ( | |||
| <Tag color={configured ? 'green' : 'grey'}> | |||
| {configured ? t('已配置') : t('Legacy 回退')} | |||
| </Tag> | |||
| ), | |||
| }, | |||
| { 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) => ( | |||
| <Space> | |||
| <Button icon={<Edit size={16} />} theme='borderless' data-testid={`model-pricing-edit-${row.model}`} onClick={() => setEditingModel(row.model)} /> | |||
| <Button | |||
| icon={<Edit size={16} />} | |||
| theme='borderless' | |||
| data-testid={`model-pricing-edit-${row.model}`} | |||
| onClick={() => setEditingModel(row.model)} | |||
| /> | |||
| {row.configured && ( | |||
| <Popconfirm | |||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { price: row.legacyPrice ?? '-' })} | |||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { | |||
| price: row.legacyPrice ?? '-', | |||
| })} | |||
| onConfirm={() => remove(row.model)} | |||
| > | |||
| <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' /> | |||
| <Button | |||
| icon={<Trash2 size={16} />} | |||
| type='danger' | |||
| theme='borderless' | |||
| /> | |||
| </Popconfirm> | |||
| )} | |||
| </Space> | |||
| @@ -102,21 +144,52 @@ export default function ModelPricing() { | |||
| return ( | |||
| <Card style={{ marginTop: 10 }} data-testid='model-pricing-page'> | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | |||
| <Space> | |||
| <Button icon={<RefreshCw size={16} />} onClick={refresh} loading={loading}> | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button icon={<Plus size={16} />} type='primary' data-testid='model-pricing-add-model' onClick={() => setEditingModel('')}> | |||
| {t('添加模型')} | |||
| </Button> | |||
| <Tabs type='line'> | |||
| <TabPane tab={t('真实计费')} itemKey='billing'> | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}> | |||
| {t('模型多维计费')} | |||
| </Typography.Title> | |||
| <Space> | |||
| <Button | |||
| icon={<RefreshCw size={16} />} | |||
| onClick={refresh} | |||
| loading={loading} | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| type='primary' | |||
| data-testid='model-pricing-add-model' | |||
| onClick={() => setEditingModel('')} | |||
| > | |||
| {t('添加模型')} | |||
| </Button> | |||
| </Space> | |||
| </Space> | |||
| <Input | |||
| placeholder={t('搜索模型')} | |||
| value={keyword} | |||
| onChange={setKeyword} | |||
| style={{ width: 280 }} | |||
| data-testid='model-pricing-search' | |||
| /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={data} | |||
| rowKey='model' | |||
| pagination={{ pageSize: 10 }} | |||
| loading={loading} | |||
| data-testid='model-pricing-table' | |||
| /> | |||
| </Space> | |||
| </Space> | |||
| <Input placeholder={t('搜索模型')} value={keyword} onChange={setKeyword} style={{ width: 280 }} data-testid='model-pricing-search' /> | |||
| <Table columns={columns} dataSource={data} rowKey='model' pagination={{ pageSize: 10 }} loading={loading} data-testid='model-pricing-table' /> | |||
| </Space> | |||
| </TabPane> | |||
| <TabPane tab={t('展示价格')} itemKey='display'> | |||
| <DisplayPricing /> | |||
| </TabPane> | |||
| </Tabs> | |||
| <Editor | |||
| visible={editingModel !== null} | |||
| modelName={editingModel || ''} | |||