- 新增 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 | ChannelTypeReplicate = 56 | ||||
| ChannelTypeCodex = 57 | ChannelTypeCodex = 57 | ||||
| ChannelTypeDoubaoVideoCompatibleAiping = 58 | ChannelTypeDoubaoVideoCompatibleAiping = 58 | ||||
| ChannelTypeKlingAiping = 59 | |||||
| ChannelTypeDummy // this one is only for count, do not add any channel after this | 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://api.replicate.com", //56 | ||||
| "https://chatgpt.com", //57 | "https://chatgpt.com", //57 | ||||
| "", //58 | "", //58 | ||||
| "https://aiping.cn/api", //59 | |||||
| } | } | ||||
| var ChannelTypeNames = map[int]string{ | var ChannelTypeNames = map[int]string{ | ||||
| @@ -178,6 +180,7 @@ var ChannelTypeNames = map[int]string{ | |||||
| ChannelTypeReplicate: "Replicate", | ChannelTypeReplicate: "Replicate", | ||||
| ChannelTypeCodex: "Codex", | ChannelTypeCodex: "Codex", | ||||
| ChannelTypeDoubaoVideoCompatibleAiping: "DoubaoVideoCompatibleAiping", | ChannelTypeDoubaoVideoCompatibleAiping: "DoubaoVideoCompatibleAiping", | ||||
| ChannelTypeKlingAiping: "KlingAiping", | |||||
| } | } | ||||
| func GetChannelTypeName(channelType int) string { | 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") { | if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | ||||
| return constant.ChannelTypeDoubaoVideoCompatibleAiping | return constant.ChannelTypeDoubaoVideoCompatibleAiping | ||||
| } | } | ||||
| if isKlingAipingNativePath(c.Request.URL.Path) { | |||||
| return constant.ChannelTypeKlingAiping | |||||
| } | |||||
| return 0 | return 0 | ||||
| } | } | ||||
| @@ -554,7 +557,9 @@ func preloadTaskPricingConfig(c *gin.Context, info *relaycommon.RelayInfo) *dto. | |||||
| if err := common.Unmarshal(body, &payload); err != nil { | if err := common.Unmarshal(body, &payload); err != nil { | ||||
| return service.TaskErrorWrapperLocal(err, "invalid_json", http.StatusBadRequest) | 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) | modelName = strings.TrimSpace(raw) | ||||
| } | } | ||||
| if raw, ok := payload["action"].(string); ok && 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 { | if taskErr.StatusCode == http.StatusTooManyRequests { | ||||
| taskErr.Message = "当前分组上游负载已饱和,请稍后再试" | taskErr.Message = "当前分组上游负载已饱和,请稍后再试" | ||||
| } | } | ||||
| if isKlingAipingNativePath(c.Request.URL.Path) { | |||||
| normalizeKlingAipingTaskError(taskErr) | |||||
| } | |||||
| c.JSON(taskErr.StatusCode, 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 { | func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool { | ||||
| if taskErr == nil { | if taskErr == nil { | ||||
| return false | 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" | "github.com/QuantumNous/new-api/relay/channel/task/hailuo" | ||||
| taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" | taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" | ||||
| "github.com/QuantumNous/new-api/relay/channel/task/kling" | "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" | tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" | ||||
| "github.com/QuantumNous/new-api/relay/channel/task/suno" | "github.com/QuantumNous/new-api/relay/channel/task/suno" | ||||
| taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" | taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" | ||||
| @@ -156,6 +157,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { | |||||
| return &taskdoubao.TaskAdaptor{} | return &taskdoubao.TaskAdaptor{} | ||||
| case constant.ChannelTypeDoubaoVideoCompatibleAiping: | case constant.ChannelTypeDoubaoVideoCompatibleAiping: | ||||
| return &taskdoubaoaiping.TaskAdaptor{} | return &taskdoubaoaiping.TaskAdaptor{} | ||||
| case constant.ChannelTypeKlingAiping: | |||||
| return &klingaiping.TaskAdaptor{} | |||||
| case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: | case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: | ||||
| return &tasksora.TaskAdaptor{} | return &tasksora.TaskAdaptor{} | ||||
| case constant.ChannelTypeGemini: | case constant.ChannelTypeGemini: | ||||
| @@ -22,6 +22,52 @@ func SetVideoRouter(router *gin.Engine) { | |||||
| videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch) | videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch) | ||||
| videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask) | 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 | // openai compatible API video routes | ||||
| // docs: https://platform.openai.com/docs/api-reference/videos/create | // 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', | color: 'blue', | ||||
| label: 'DoubaoVideoCompatibleAiping', | label: 'DoubaoVideoCompatibleAiping', | ||||
| }, | }, | ||||
| { | |||||
| value: 59, | |||||
| color: 'green', | |||||
| label: 'KlingAiping', | |||||
| }, | |||||
| ]; | ]; | ||||
| export const MODEL_TABLE_PAGE_SIZE = 10; | export const MODEL_TABLE_PAGE_SIZE = 10; | ||||
| @@ -395,6 +395,7 @@ export function getChannelIcon(channelType) { | |||||
| case 49: // Coze | case 49: // Coze | ||||
| return <Coze size={iconSize} />; | return <Coze size={iconSize} />; | ||||
| case 50: // 可灵 Kling | case 50: // 可灵 Kling | ||||
| case 59: // KlingAiping | |||||
| return <Kling.Color size={iconSize} />; | return <Kling.Color size={iconSize} />; | ||||
| case 51: // 即梦 Jimeng | case 51: // 即梦 Jimeng | ||||
| return <Jimeng.Color size={iconSize} />; | 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 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 { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { API, showError, showSuccess } from '../../../../helpers'; | import { API, showError, showSuccess } from '../../../../helpers'; | ||||
| import DisplayPricing from './DisplayPricing'; | |||||
| import Editor from './Editor'; | import Editor from './Editor'; | ||||
| export default function ModelPricing() { | export default function ModelPricing() { | ||||
| @@ -16,14 +28,19 @@ export default function ModelPricing() { | |||||
| const refresh = async () => { | const refresh = async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | 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) { | if (rulesRes.data?.success) { | ||||
| setRules(rulesRes.data.data || {}); | setRules(rulesRes.data.data || {}); | ||||
| } else { | } else { | ||||
| showError(rulesRes.data?.message || t('加载模型多维计费失败')); | showError(rulesRes.data?.message || t('加载模型多维计费失败')); | ||||
| } | } | ||||
| if (optionsRes.data?.success) { | 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)); | setLegacyPrices(parseJsonObject(modelPrice)); | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| @@ -38,7 +55,10 @@ export default function ModelPricing() { | |||||
| }, []); | }, []); | ||||
| const data = useMemo(() => { | 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] | return [...names] | ||||
| .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | ||||
| .sort() | .sort() | ||||
| @@ -57,7 +77,9 @@ export default function ModelPricing() { | |||||
| const remove = async (model) => { | const remove = async (model) => { | ||||
| try { | 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) { | if (!res.data?.success) { | ||||
| showError(res.data?.message || t('删除模型多维计费失败')); | showError(res.data?.message || t('删除模型多维计费失败')); | ||||
| return; | return; | ||||
| @@ -75,24 +97,44 @@ export default function ModelPricing() { | |||||
| title: t('状态'), | title: t('状态'), | ||||
| dataIndex: 'configured', | dataIndex: 'configured', | ||||
| width: 120, | 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: 'dimensions', width: 90 }, | ||||
| { title: t('行数'), dataIndex: 'rows', width: 90 }, | { title: t('行数'), dataIndex: 'rows', width: 90 }, | ||||
| { title: t('计费单位'), dataIndex: 'billingUnit', width: 150 }, | { 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('操作'), | title: t('操作'), | ||||
| width: 120, | width: 120, | ||||
| render: (_, row) => ( | render: (_, row) => ( | ||||
| <Space> | <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 && ( | {row.configured && ( | ||||
| <Popconfirm | <Popconfirm | ||||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { price: row.legacyPrice ?? '-' })} | |||||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { | |||||
| price: row.legacyPrice ?? '-', | |||||
| })} | |||||
| onConfirm={() => remove(row.model)} | onConfirm={() => remove(row.model)} | ||||
| > | > | ||||
| <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' /> | |||||
| <Button | |||||
| icon={<Trash2 size={16} />} | |||||
| type='danger' | |||||
| theme='borderless' | |||||
| /> | |||||
| </Popconfirm> | </Popconfirm> | ||||
| )} | )} | ||||
| </Space> | </Space> | ||||
| @@ -102,21 +144,52 @@ export default function ModelPricing() { | |||||
| return ( | return ( | ||||
| <Card style={{ marginTop: 10 }} data-testid='model-pricing-page'> | <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> | ||||
| </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 | <Editor | ||||
| visible={editingModel !== null} | visible={editingModel !== null} | ||||
| modelName={editingModel || ''} | modelName={editingModel || ''} | ||||