| @@ -28,6 +28,12 @@ CLAUDE.md | |||||
| logs/ | logs/ | ||||
| docs/superpowers | docs/superpowers | ||||
| # Runtime request/response capture files | |||||
| /[0-9]*.json | |||||
| # Local dev server logs | |||||
| web/vite-*.log | |||||
| # E2E / Playwright artifacts | # E2E / Playwright artifacts | ||||
| test-artifacts/ | test-artifacts/ | ||||
| web/.playwright/ | web/.playwright/ | ||||
| @@ -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,484 @@ | |||||
| 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/logger" | |||||
| "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) | |||||
| if bound := resolveKlingAipingBoundChannel(c, relayInfo); bound != nil { | |||||
| relayInfo.LockedChannel = bound | |||||
| } | |||||
| relayTaskWithInfo(c, relayInfo) | |||||
| } | |||||
| func resolveKlingAipingBoundChannel(c *gin.Context, relayInfo *relaycommon.RelayInfo) *model.Channel { | |||||
| group := concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup) | |||||
| if group == "" { | |||||
| return nil | |||||
| } | |||||
| channel, err := service.ResolveKlingAssetChannel(c.GetInt("id"), group) | |||||
| if err != nil { | |||||
| logger.LogError(c, fmt.Sprintf("resolve kling aiping bound channel failed: %v", err)) | |||||
| return nil | |||||
| } | |||||
| if channel == nil || !service.IsUsableKlingAssetChannel(channel, group) { | |||||
| return nil | |||||
| } | |||||
| if !model.IsChannelEnabledForGroupModel(group, relayInfo.OriginModelName, channel.Id) { | |||||
| return nil | |||||
| } | |||||
| return channel | |||||
| } | |||||
| 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 | |||||
| } | |||||
| group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) | |||||
| channel := resolveKlingAipingBoundChannelForProxy(c, group, route.BillingModel) | |||||
| if channel == nil { | |||||
| var err error | |||||
| channel, _, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ | |||||
| Ctx: c, | |||||
| TokenGroup: group, | |||||
| 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 resolveKlingAipingBoundChannelForProxy(c *gin.Context, group, billingModel string) *model.Channel { | |||||
| group = strings.TrimSpace(group) | |||||
| if group == "" || group == "auto" || strings.TrimSpace(billingModel) == "" { | |||||
| return nil | |||||
| } | |||||
| channel, err := service.ResolveKlingAssetChannel(c.GetInt("id"), group) | |||||
| if err != nil { | |||||
| logger.LogError(c, fmt.Sprintf("resolve kling aiping bound channel (proxy) failed: %v", err)) | |||||
| return nil | |||||
| } | |||||
| if channel == nil || !service.IsUsableKlingAssetChannel(channel, group) { | |||||
| return nil | |||||
| } | |||||
| if !model.IsChannelEnabledForGroupModel(group, billingModel, channel.Id) { | |||||
| return nil | |||||
| } | |||||
| return channel | |||||
| } | |||||
| 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 | |||||
| } | |||||
| return storage.Bytes() | |||||
| } | |||||
| 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,225 @@ | |||||
| 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/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/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-extend", `{}`) | |||||
| c.Request.URL.Path = "/v1/videos/video-extend" | |||||
| 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 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/general/advanced-presets-elements", ``) | |||||
| common.SetContextKey(c, constant.ContextKeyChannelKey, "selected-key") | |||||
| route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/general/advanced-presets-elements", 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") | |||||
| } | |||||
| @@ -0,0 +1,59 @@ | |||||
| package controller | |||||
| import ( | |||||
| "net/http" | |||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | |||||
| ) | |||||
| func GetModelDisplayPricingRules(c *gin.Context) { | |||||
| common.ApiSuccess(c, ratio_setting.GetModelDisplayPricingCopy()) | |||||
| } | |||||
| func GetModelDisplayPricingRule(c *gin.Context) { | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| items := ratio_setting.GetModelDisplayPricing(modelName) | |||||
| if len(items) == 0 { | |||||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model display pricing not found"}) | |||||
| return | |||||
| } | |||||
| common.ApiSuccess(c, items) | |||||
| } | |||||
| func UpdateModelDisplayPricingRule(c *gin.Context) { | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| var items []types.ModelDisplayPricingItem | |||||
| if err := common.DecodeJson(c.Request.Body, &items); err != nil { | |||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | |||||
| return | |||||
| } | |||||
| if err := ratio_setting.SetModelDisplayPricing(modelName, items); err != nil { | |||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | |||||
| return | |||||
| } | |||||
| if err := model.UpdateOption(ratio_setting.ModelDisplayPricingOptionKey, ratio_setting.ModelDisplayPricing2JSONString()); err != nil { | |||||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) | |||||
| return | |||||
| } | |||||
| model.RefreshPricing() | |||||
| common.ApiSuccess(c, ratio_setting.GetModelDisplayPricing(modelName)) | |||||
| } | |||||
| func DeleteModelDisplayPricingRule(c *gin.Context) { | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| if err := ratio_setting.DeleteModelDisplayPricing(modelName); err != nil { | |||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | |||||
| return | |||||
| } | |||||
| if err := model.UpdateOption(ratio_setting.ModelDisplayPricingOptionKey, ratio_setting.ModelDisplayPricing2JSONString()); err != nil { | |||||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) | |||||
| return | |||||
| } | |||||
| model.RefreshPricing() | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| @@ -0,0 +1,96 @@ | |||||
| package controller | |||||
| import ( | |||||
| "net/http" | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | |||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/require" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| func setupModelDisplayPricingControllerTest(t *testing.T) *gin.Engine { | |||||
| t.Helper() | |||||
| gin.SetMode(gin.TestMode) | |||||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||||
| require.NoError(t, err) | |||||
| require.NoError(t, db.AutoMigrate(&model.Option{})) | |||||
| sqlDB, err := db.DB() | |||||
| require.NoError(t, err) | |||||
| sqlDB.SetMaxOpenConns(1) | |||||
| origDB := model.DB | |||||
| origUsingSQLite := common.UsingSQLite | |||||
| origRedisEnabled := common.RedisEnabled | |||||
| model.DB = db | |||||
| common.UsingSQLite = true | |||||
| common.RedisEnabled = false | |||||
| model.InitOptionMap() | |||||
| require.NoError(t, ratio_setting.UpdateModelDisplayPricingByJSONString(`{}`)) | |||||
| t.Cleanup(func() { | |||||
| model.DB = origDB | |||||
| common.UsingSQLite = origUsingSQLite | |||||
| common.RedisEnabled = origRedisEnabled | |||||
| require.NoError(t, ratio_setting.UpdateModelDisplayPricingByJSONString(`{}`)) | |||||
| require.NoError(t, sqlDB.Close()) | |||||
| }) | |||||
| r := gin.New() | |||||
| r.GET("/api/option/model_display_pricing", GetModelDisplayPricingRules) | |||||
| r.GET("/api/option/model_display_pricing/*model", GetModelDisplayPricingRule) | |||||
| r.PUT("/api/option/model_display_pricing/*model", UpdateModelDisplayPricingRule) | |||||
| r.DELETE("/api/option/model_display_pricing/*model", DeleteModelDisplayPricingRule) | |||||
| return r | |||||
| } | |||||
| func TestModelDisplayPricingControllerCRUDAndWildcard(t *testing.T) { | |||||
| r := setupModelDisplayPricingControllerTest(t) | |||||
| items := []types.ModelDisplayPricingItem{ | |||||
| {Specification: "768p-6s", OfficialSupplierTip: "768P 6s", Price: 0.33333, Unit: "second", SortOrder: 1}, | |||||
| } | |||||
| w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_display_pricing/provider/kling-video", items) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Len(t, ratio_setting.GetModelDisplayPricing("provider/kling-video"), 1) | |||||
| w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_display_pricing/provider/kling-video", nil) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Contains(t, w.Body.String(), "768p-6s") | |||||
| w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_display_pricing", nil) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Contains(t, w.Body.String(), "provider/kling-video") | |||||
| w = performJSONRequest(t, r, http.MethodDelete, "/api/option/model_display_pricing/provider/kling-video", nil) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Empty(t, ratio_setting.GetModelDisplayPricing("provider/kling-video")) | |||||
| } | |||||
| func TestModelDisplayPricingControllerValidationError(t *testing.T) { | |||||
| r := setupModelDisplayPricingControllerTest(t) | |||||
| items := []types.ModelDisplayPricingItem{{Specification: "", Price: 1, Unit: "second"}} | |||||
| w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_display_pricing/kling-video", items) | |||||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||||
| require.Contains(t, w.Body.String(), "specification is required") | |||||
| } | |||||
| func TestModelDisplayPricingOptionMapHydratesCache(t *testing.T) { | |||||
| r := setupModelDisplayPricingControllerTest(t) | |||||
| require.NotNil(t, r) | |||||
| require.NoError(t, model.UpdateOption(ratio_setting.ModelDisplayPricingOptionKey, `{ | |||||
| "kling-video":[{"specification":"768p","price":1,"unit":"second"}] | |||||
| }`)) | |||||
| items := ratio_setting.GetModelDisplayPricing("kling-video") | |||||
| require.Len(t, items, 1) | |||||
| require.Equal(t, "768p", items[0].Specification) | |||||
| } | |||||
| @@ -2,6 +2,7 @@ package controller | |||||
| import ( | import ( | ||||
| "net/http" | "net/http" | ||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| @@ -15,7 +16,7 @@ func GetModelPricingRules(c *gin.Context) { | |||||
| } | } | ||||
| func GetModelPricingRule(c *gin.Context) { | func GetModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| cfg := ratio_setting.GetPricingConfig(modelName) | cfg := ratio_setting.GetPricingConfig(modelName) | ||||
| if cfg == nil { | if cfg == nil { | ||||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"}) | c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"}) | ||||
| @@ -25,7 +26,7 @@ func GetModelPricingRule(c *gin.Context) { | |||||
| } | } | ||||
| func UpdateModelPricingRule(c *gin.Context) { | func UpdateModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| var cfg types.PricingConfig | var cfg types.PricingConfig | ||||
| if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { | if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { | ||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | ||||
| @@ -44,7 +45,7 @@ func UpdateModelPricingRule(c *gin.Context) { | |||||
| } | } | ||||
| func DeleteModelPricingRule(c *gin.Context) { | func DeleteModelPricingRule(c *gin.Context) { | ||||
| modelName := c.Param("model") | |||||
| modelName := strings.TrimPrefix(c.Param("model"), "/") | |||||
| if err := ratio_setting.DeletePricingConfig(modelName); err != nil { | if err := ratio_setting.DeletePricingConfig(modelName); err != nil { | ||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) | ||||
| return | return | ||||
| @@ -49,9 +49,9 @@ func setupModelPricingControllerTest(t *testing.T) *gin.Engine { | |||||
| r := gin.New() | r := gin.New() | ||||
| r.GET("/api/option/model_pricing", GetModelPricingRules) | r.GET("/api/option/model_pricing", GetModelPricingRules) | ||||
| r.GET("/api/option/model_pricing/:model", GetModelPricingRule) | |||||
| r.PUT("/api/option/model_pricing/:model", UpdateModelPricingRule) | |||||
| r.DELETE("/api/option/model_pricing/:model", DeleteModelPricingRule) | |||||
| r.GET("/api/option/model_pricing/*model", GetModelPricingRule) | |||||
| r.PUT("/api/option/model_pricing/*model", UpdateModelPricingRule) | |||||
| r.DELETE("/api/option/model_pricing/*model", DeleteModelPricingRule) | |||||
| return r | return r | ||||
| } | } | ||||
| @@ -94,6 +94,24 @@ func TestModelPricingController_PutRejectsBadConfig(t *testing.T) { | |||||
| assert.Contains(t, w.Body.String(), "table must not be empty") | assert.Contains(t, w.Body.String(), "table must not be empty") | ||||
| } | } | ||||
| func TestModelPricingController_CRUDWithSlashModelName(t *testing.T) { | |||||
| r := setupModelPricingControllerTest(t) | |||||
| cfg := validControllerPricingConfig() | |||||
| modelName := "openai/gpt-4o" | |||||
| w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_pricing/"+modelName, cfg) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.NotNil(t, ratio_setting.GetPricingConfig(modelName)) | |||||
| w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_pricing/"+modelName, nil) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| assert.Contains(t, w.Body.String(), `"billing_unit":"per_call"`) | |||||
| w = performJSONRequest(t, r, http.MethodDelete, "/api/option/model_pricing/"+modelName, nil) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| assert.Nil(t, ratio_setting.GetPricingConfig(modelName)) | |||||
| } | |||||
| func performJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { | func performJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { | ||||
| t.Helper() | t.Helper() | ||||
| var payload []byte | var payload []byte | ||||
| @@ -138,6 +138,9 @@ func GetUserPricing(c *gin.Context) { | |||||
| if pricingData.PricingConfig != nil { | if pricingData.PricingConfig != nil { | ||||
| result["pricing_config"] = pricingData.PricingConfig | result["pricing_config"] = pricingData.PricingConfig | ||||
| } | } | ||||
| if len(pricingData.DisplayPricing) > 0 { | |||||
| result["display_pricing"] = pricingData.DisplayPricing | |||||
| } | |||||
| if pricingData.QuotaType == model.QuotaTypeByTokens { | if pricingData.QuotaType == model.QuotaTypeByTokens { | ||||
| if pricingData.PricingConfig != nil { | if pricingData.PricingConfig != nil { | ||||
| @@ -184,6 +187,9 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { | |||||
| } | } | ||||
| result["original_price"] = pricingData.ModelPrice | result["original_price"] = pricingData.ModelPrice | ||||
| } | } | ||||
| if len(pricingData.DisplayPricing) > 0 { | |||||
| result["display_pricing"] = pricingData.DisplayPricing | |||||
| } | |||||
| c.JSON(200, result) | c.JSON(200, result) | ||||
| } | } | ||||
| @@ -9,8 +9,9 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| "gorm.io/gorm" | "gorm.io/gorm" | ||||
| @@ -152,6 +153,74 @@ func TestGetUserPricing_NotLoggedIn_PerCall(t *testing.T) { | |||||
| assert.Equal(t, float64(0.04), resp["original_price"]) | assert.Equal(t, float64(0.04), resp["original_price"]) | ||||
| } | } | ||||
| func TestGetUserPricingIncludesDisplayPricingWithoutChangingComputedPrice(t *testing.T) { | |||||
| setupPricingTestDB(t) | |||||
| modelName := "display-pricing-user-model" | |||||
| router := gin.New() | |||||
| router.GET("/api/pricing/user/*model", GetUserPricing) | |||||
| setPricingCache([]model.Pricing{ | |||||
| { | |||||
| ModelName: modelName, | |||||
| QuotaType: model.QuotaTypeByCall, | |||||
| ModelPrice: 0.5, | |||||
| EnableGroup: []string{"default"}, | |||||
| DisplayPricing: []types.ModelDisplayPricingItem{ | |||||
| {Specification: "768p-6s", Price: 0.33333, Unit: "second", SortOrder: 1, DiscountRate: 1}, | |||||
| }, | |||||
| }, | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/pricing/user/"+modelName, nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Contains(t, resp, "display_pricing") | |||||
| require.Equal(t, float64(0.5), resp["original_price"]) | |||||
| require.NotEqual(t, float64(0.33333), resp["original_price"]) | |||||
| } | |||||
| func TestGetPricingIncludesDisplayPricingForModelSquare(t *testing.T) { | |||||
| setupPricingTestDB(t) | |||||
| modelName := "display-pricing-square-model" | |||||
| router := gin.New() | |||||
| router.GET("/api/pricing", GetPricing) | |||||
| setPricingCache([]model.Pricing{ | |||||
| { | |||||
| ModelName: modelName, | |||||
| QuotaType: model.QuotaTypeByCall, | |||||
| ModelPrice: 0.5, | |||||
| EnableGroup: []string{"default"}, | |||||
| DisplayPricing: []types.ModelDisplayPricingItem{ | |||||
| {Specification: "768p-6s", Price: 0.33333, Unit: "second", SortOrder: 1, DiscountRate: 1}, | |||||
| }, | |||||
| }, | |||||
| }) | |||||
| withGroupRatio(t, `{"default":1}`) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/pricing", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Equal(t, true, resp["success"]) | |||||
| data := resp["data"].([]interface{}) | |||||
| require.Len(t, data, 1) | |||||
| item := data[0].(map[string]interface{}) | |||||
| require.Equal(t, modelName, item["model_name"]) | |||||
| require.Contains(t, item, "display_pricing") | |||||
| displayPricing := item["display_pricing"].([]interface{}) | |||||
| require.Len(t, displayPricing, 1) | |||||
| require.Equal(t, "768p-6s", displayPricing[0].(map[string]interface{})["specification"]) | |||||
| } | |||||
| // TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率) | // TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率) | ||||
| func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { | func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { | ||||
| db := setupPricingTestDB(t) | db := setupPricingTestDB(t) | ||||
| @@ -226,8 +295,6 @@ func TestGetUserPricing_LoggedIn_GroupDiscount(t *testing.T) { | |||||
| assert.Equal(t, "8折", resp["discount"]) | assert.Equal(t, "8折", resp["discount"]) | ||||
| } | } | ||||
| // TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣 | // TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣 | ||||
| func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) { | func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) { | ||||
| db := setupPricingTestDB(t) | db := setupPricingTestDB(t) | ||||
| @@ -280,4 +347,3 @@ func TestFormatDiscount(t *testing.T) { | |||||
| assert.Equal(t, tt.expected, result, "ratio=%.2f", tt.ratio) | assert.Equal(t, tt.expected, result, "ratio=%.2f", tt.ratio) | ||||
| } | } | ||||
| } | } | ||||
| @@ -296,7 +296,7 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta { | |||||
| return meta | return meta | ||||
| } | } | ||||
| func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { | |||||
| func concreteTaskVideoBindingGroup(c *gin.Context, group string) string { | |||||
| group = strings.TrimSpace(group) | group = strings.TrimSpace(group) | ||||
| if group == "" { | if group == "" { | ||||
| group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) | group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) | ||||
| @@ -311,15 +311,24 @@ func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { | |||||
| return group | return group | ||||
| } | } | ||||
| func persistDoubaoVideoTaskBindingIfNeeded(userId int, group string, channel *model.Channel) error { | |||||
| func persistTaskVideoBindingIfNeeded(userId int, group string, channel *model.Channel) error { | |||||
| group = strings.TrimSpace(group) | group = strings.TrimSpace(group) | ||||
| if group == "" || group == "auto" { | |||||
| if group == "" || group == "auto" || channel == nil { | |||||
| return nil | return nil | ||||
| } | } | ||||
| if !service.IsUsableDoubaoAssetChannel(channel, group) { | |||||
| return nil | |||||
| switch channel.Type { | |||||
| case service.DoubaoAssetChannelType: | |||||
| if !service.IsUsableDoubaoAssetChannel(channel, group) { | |||||
| return nil | |||||
| } | |||||
| return model.BindUserAssetChannel(userId, service.DoubaoAssetChannelType, group, channel.Id) | |||||
| case constant.ChannelTypeKlingAiping: | |||||
| if !service.IsUsableKlingAssetChannel(channel, group) { | |||||
| return nil | |||||
| } | |||||
| return model.BindUserAssetChannel(userId, constant.ChannelTypeKlingAiping, group, channel.Id) | |||||
| } | } | ||||
| return model.BindUserAssetChannel(userId, service.DoubaoAssetChannelType, group, channel.Id) | |||||
| return nil | |||||
| } | } | ||||
| func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, string, *types.NewAPIError) { | func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, string, *types.NewAPIError) { | ||||
| @@ -334,7 +343,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service | |||||
| Type: c.GetInt("channel_type"), | Type: c.GetInt("channel_type"), | ||||
| Name: c.GetString("channel_name"), | Name: c.GetString("channel_name"), | ||||
| AutoBan: &autoBanInt, | AutoBan: &autoBanInt, | ||||
| }, concreteDoubaoVideoBindingGroup(c, info.TokenGroup), nil | |||||
| }, concreteTaskVideoBindingGroup(c, info.TokenGroup), nil | |||||
| } | } | ||||
| channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) | channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) | ||||
| @@ -361,6 +370,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 +566,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) != "" { | ||||
| @@ -663,7 +677,7 @@ func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) { | |||||
| if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { | if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { | ||||
| channel = lockedCh | channel = lockedCh | ||||
| selectedGroup = concreteDoubaoVideoBindingGroup(c, relayInfo.TokenGroup) | |||||
| selectedGroup = concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup) | |||||
| if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { | if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { | ||||
| taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) | taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) | ||||
| break | break | ||||
| @@ -678,8 +692,8 @@ func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) { | |||||
| } | } | ||||
| } | } | ||||
| if bindErr := persistDoubaoVideoTaskBindingIfNeeded(c.GetInt("id"), selectedGroup, channel); bindErr != nil { | |||||
| taskErr = service.TaskErrorWrapperLocal(bindErr, "bind_doubao_video_channel_failed", http.StatusServiceUnavailable) | |||||
| if bindErr := persistTaskVideoBindingIfNeeded(c.GetInt("id"), selectedGroup, channel); bindErr != nil { | |||||
| taskErr = service.TaskErrorWrapperLocal(bindErr, "bind_task_video_channel_failed", http.StatusServiceUnavailable) | |||||
| break | break | ||||
| } | } | ||||
| @@ -750,9 +764,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/video-extend", | |||||
| "/v1/general/advanced-custom-elements", | |||||
| "/v1/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,322 @@ | |||||
| # Model Display Pricing Design | |||||
| ## Background | |||||
| The current pricing system mixes two responsibilities in one structure: | |||||
| - Real billing reads `PricingConfig.table[].price` through relay pricing lookup, freezes it into `PricingDecision`, and later uses that decision for pre-consumption and final settlement. | |||||
| - The pricing page also reads `PricingConfig.table[].price` to render model price summaries and matrix details. | |||||
| Some video providers need a user-facing estimate such as `768p-6s, price per second`, while the actual charge is settled by token usage, provider-returned deduction, or another backend billing result. Reusing `PricingConfig` for this would make display-only prices affect real billing. | |||||
| ## Goal | |||||
| Add a display-only pricing configuration that lets the frontend show a supplier-style price table for user estimation. | |||||
| This configuration must: | |||||
| - Use USD as the base amount so the existing frontend currency conversion can support USD, CNY, and custom currencies. | |||||
| - Never participate in quota pre-consumption, final settlement, token billing, task polling, or billing logs. | |||||
| - Override only the public pricing page display when configured. | |||||
| - Preserve current pricing behavior for models without display pricing. | |||||
| ## Non-Goals | |||||
| - Do not change how real billing is calculated. | |||||
| - Do not add formula-based display pricing in this iteration. | |||||
| - Do not replace `ModelPricingRules`; real matrix pricing remains unchanged. | |||||
| - Do not calculate exact task cost from display pricing. It is only an estimate. | |||||
| ## Data Model | |||||
| Add a new option key, separate from `ModelPricingRules`: | |||||
| ```text | |||||
| ModelDisplayPricing | |||||
| ``` | |||||
| The option value is a JSON map keyed by model name: | |||||
| ```json | |||||
| { | |||||
| "kling-video": [ | |||||
| { | |||||
| "specification": "768p-6s", | |||||
| "official_supplier_tip": "Text-to-video and image-to-video, 768P 6s", | |||||
| "price": 0.33333, | |||||
| "unit": "second", | |||||
| "sort_order": 1, | |||||
| "discount_rate": 1 | |||||
| }, | |||||
| { | |||||
| "specification": "768p-10s", | |||||
| "official_supplier_tip": "Text-to-video and image-to-video, 768P 10s", | |||||
| "price": 0.4, | |||||
| "unit": "second", | |||||
| "sort_order": 2, | |||||
| "discount_rate": 1 | |||||
| }, | |||||
| { | |||||
| "specification": "1080p", | |||||
| "official_supplier_tip": "Text-to-video and image-to-video, 1080P", | |||||
| "price": 0.58333, | |||||
| "unit": "second", | |||||
| "sort_order": 3, | |||||
| "discount_rate": 1 | |||||
| } | |||||
| ] | |||||
| } | |||||
| ``` | |||||
| Suggested Go type: | |||||
| ```go | |||||
| type ModelDisplayPricingItem struct { | |||||
| Specification string `json:"specification"` | |||||
| OfficialSupplierTip string `json:"official_supplier_tip"` | |||||
| Price float64 `json:"price"` | |||||
| Unit string `json:"unit"` | |||||
| SortOrder int `json:"sort_order"` | |||||
| DiscountRate float64 `json:"discount_rate"` | |||||
| } | |||||
| ``` | |||||
| Field semantics: | |||||
| - `specification`: short SKU label shown in compact and detail views. | |||||
| - `official_supplier_tip`: supplier-facing explanation shown in detail view. | |||||
| - `price`: USD base display price before discount. This is not quota and not billing input. | |||||
| - `unit`: display unit, such as `second`, `call`, `image`, or `video`. | |||||
| - `sort_order`: stable ordering for the frontend and API response. | |||||
| - `discount_rate`: display-only multiplier. `0` or omitted means no discount and is normalized to `1`. | |||||
| Validation rules: | |||||
| - Model key must not be empty. | |||||
| - `specification` must not be empty. | |||||
| - `price` must be greater than or equal to `0`. | |||||
| - `unit` must not be empty. | |||||
| - `discount_rate` is normalized before validation: `0` or omitted becomes `1`; after normalization, the accepted range is `0 < discount_rate <= 1`. | |||||
| - Items should be returned sorted by `sort_order`, then `specification`. | |||||
| ## Backend Design | |||||
| Add a new ratio setting module, parallel to `model_pricing.go`, for display pricing only: | |||||
| - `GetModelDisplayPricing(model string) []types.ModelDisplayPricingItem` | |||||
| - `GetModelDisplayPricingCopy() map[string][]types.ModelDisplayPricingItem` | |||||
| - `SetModelDisplayPricing(model string, items []types.ModelDisplayPricingItem) error` | |||||
| - `DeleteModelDisplayPricing(model string) error` | |||||
| - `UpdateModelDisplayPricingByJSONString(jsonStr string) error` | |||||
| - `ModelDisplayPricing2JSONString() string` | |||||
| Store the JSON in `options`, matching the existing config pattern and avoiding a cross-database migration for a display-only setting. | |||||
| The option lifecycle must follow the existing `ModelPricingRules` pattern: | |||||
| - Add `ModelDisplayPricing` to `model.InitOptionMap()` with an empty JSON object default. | |||||
| - Add `case "ModelDisplayPricing"` to `model.updateOptionMap()` so DB-loaded values hydrate the in-memory display pricing cache during startup and option sync. | |||||
| - Persist updates through `model.UpdateOption(ModelDisplayPricingOptionKey, ModelDisplayPricing2JSONString())`. | |||||
| - Call `model.RefreshPricing()` after successful create, update, or delete so `/api/pricing` reflects the new display data immediately. | |||||
| Extend `model.Pricing`: | |||||
| ```go | |||||
| DisplayPricing []types.ModelDisplayPricingItem `json:"display_pricing,omitempty"` | |||||
| ``` | |||||
| During `model.updatePricing()`, after the model metadata and real pricing fields are assembled, attach display pricing for the model if configured. This keeps `/api/pricing` as the single source used by the existing pricing page. | |||||
| `/api/pricing/user/*model` should also return `display_pricing` when present, but it must not use it for `original_price`, `user_price`, `original_input`, `original_output`, or savings calculation. | |||||
| Admin APIs should mirror the existing model pricing rule APIs: | |||||
| - `GET /api/option/model_display_pricing` | |||||
| - `GET /api/option/model_display_pricing/*model` | |||||
| - `PUT /api/option/model_display_pricing/*model` | |||||
| - `DELETE /api/option/model_display_pricing/*model` | |||||
| Use wildcard model parameters, matching `/api/pricing/user/*model`, so model names containing `/` can be configured without URL-shape bugs. These endpoints manage only display data and call `model.RefreshPricing()` after updates. | |||||
| ## Frontend Design | |||||
| The display pricing UI is part of the existing model pricing page. It should be implemented as a display override on top of the current `/api/pricing` response, not as a new standalone public page. | |||||
| Affected frontend entry points: | |||||
| - `web/src/hooks/model-pricing/useModelPricingData.jsx`: keep loading `/api/pricing`; no extra fetch is needed for public display. | |||||
| - `web/src/helpers/utils.jsx`: add shared helpers so table, card, and detail views render the same display price. | |||||
| - `web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx`: compact price column display. | |||||
| - `web/src/components/table/model-pricing/view/card/PricingCardView.jsx`: compact card display. | |||||
| - `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx`: full detail table display. | |||||
| - `web/src/components/table/model-pricing/modal/components/UserPriceComparison.jsx`: hide when display pricing exists. | |||||
| Add display pricing helpers in the model pricing utilities: | |||||
| - `hasDisplayPricing(record)` | |||||
| - `normalizeDisplayPricingItems(record)` | |||||
| - `formatDisplayPricingItem({ item, displayPrice, t })` | |||||
| - `summarizeDisplayPricing({ record, displayPrice, t })` | |||||
| Display calculation: | |||||
| ```js | |||||
| original = displayPrice(item.price) | |||||
| discounted = displayPrice(item.price * item.discount_rate) | |||||
| ``` | |||||
| If `discount_rate` is `1`, show only the regular price. If lower than `1`, show discounted price and optionally the original price with strikethrough. | |||||
| Unit labels should be translated in the frontend. The API returns stable unit keys such as `second`, `call`, `image`, and `video`; the UI maps them to localized labels such as `/ second` or `/ sec` depending on existing i18n conventions. | |||||
| Display priority: | |||||
| 1. If `record.display_pricing` exists and has items, render display pricing. | |||||
| 2. Otherwise, keep the existing matrix pricing, per-token pricing, and per-call pricing behavior. | |||||
| ### Pricing Table Display | |||||
| In the main table price column, display pricing replaces the current matrix/per-token/per-call price block for that model. | |||||
| For multiple display pricing items with the same unit, show a converted price range: | |||||
| ```text | |||||
| CNY 2.41 - CNY 4.22 / second | |||||
| Display price | |||||
| ``` | |||||
| For one item, or when all items have the same discounted price: | |||||
| ```text | |||||
| CNY 2.41 / second | |||||
| Display price | |||||
| ``` | |||||
| For multiple units, group by unit and render one short line per unit: | |||||
| ```text | |||||
| CNY 2.41 - CNY 4.22 / second | |||||
| CNY 1.00 / image | |||||
| Display price | |||||
| ``` | |||||
| The compact table view should not render `official_supplier_tip`; that belongs in the detail view. | |||||
| ### Pricing Card Display | |||||
| Cards should use the same summary output as the table column, with at most the first three display items shown below the range: | |||||
| ```text | |||||
| CNY 2.41 - CNY 4.22 / second | |||||
| 768p-6s CNY 2.41 / second | |||||
| 768p-10s CNY 2.90 / second | |||||
| 1080p CNY 4.22 / second | |||||
| + 2 more | |||||
| ``` | |||||
| If the card is too narrow on mobile, keep only the range and the `Display price` label. Do not render a horizontal table inside the card. | |||||
| ### Detail Side Sheet Display | |||||
| When `display_pricing` exists, `ModelPricingTable` should render a dedicated display pricing table instead of the existing group price table or matrix table. | |||||
| | Specification | Supplier Tip | Price | Discount | | |||||
| | --- | --- | --- | --- | | |||||
| | 768p-6s | Text-to-video and image-to-video, 768P 6s | CNY 2.41 / second | - | | |||||
| | 768p-10s | Text-to-video and image-to-video, 768P 10s | CNY 2.90 / second | - | | |||||
| | 1080p | Text-to-video and image-to-video, 1080P | CNY 4.22 / second | - | | |||||
| For discounted items, render the discounted price as the primary value and the original converted price as secondary strikethrough text: | |||||
| ```text | |||||
| CNY 1.93 / second | |||||
| Original CNY 2.41, 20% off | |||||
| ``` | |||||
| Every display pricing block must include an i18n note with this meaning: | |||||
| ```text | |||||
| Display price is only an estimate. Actual consumption is based on the final quota deduction after the task completes. | |||||
| ``` | |||||
| The note must appear in the detail side sheet and in any compact tooltip/popover if one is added later. The main table and card can show only a short `Display price` label to avoid visual noise. | |||||
| ### User Price Comparison | |||||
| When a model has `display_pricing`, hide the old `UserPriceComparison` discount card. The detail side sheet should show the display pricing table and the estimate note instead. The current discount card is derived from real billing fields and would be misleading for models whose visible price is only an estimate. | |||||
| `/api/pricing/user/*model` may still return `display_pricing` for consistency, but the frontend should not calculate user-specific discounted display pricing from `original_price` or `user_price`. | |||||
| ### Admin Editing UI | |||||
| The first implementation may manage `ModelDisplayPricing` through JSON option editing or a minimal admin form. If a structured form is added, it should expose only display fields: | |||||
| - `specification` | |||||
| - `official_supplier_tip` | |||||
| - `price` as USD base price | |||||
| - `unit` | |||||
| - `sort_order` | |||||
| - `discount_rate` | |||||
| The editor must label `price` as USD base display price and include a warning that it does not affect real billing. | |||||
| ## Billing Isolation | |||||
| The implementation must not reference display pricing in: | |||||
| - `relay/helper/price.go` | |||||
| - `relay/helper/pricing_lookup.go` | |||||
| - `controller/relay.go` pricing preload logic | |||||
| - `service/task_polling.go` | |||||
| - `types.PricingDecision` | |||||
| - task final deduction calculation | |||||
| - quota pre-consumption | |||||
| - consume logs used for settlement | |||||
| Only admin display-pricing config APIs, `model.Pricing`, and pricing-page APIs should expose it. Relay, billing, task, and consume-log paths must not read it. | |||||
| ## Data Flow | |||||
| ```text | |||||
| Admin saves display pricing | |||||
| -> options.ModelDisplayPricing | |||||
| -> ratio_setting display pricing cache | |||||
| -> model.RefreshPricing() | |||||
| -> model.updatePricing() attaches display_pricing | |||||
| -> GET /api/pricing returns display_pricing | |||||
| -> frontend price page renders display SKU table | |||||
| User creates a task | |||||
| -> relay pricing preload reads ModelPricingRules or legacy price/ratio | |||||
| -> PricingDecision is frozen | |||||
| -> task completes | |||||
| -> final settlement uses real billing data | |||||
| -> display_pricing is never read | |||||
| ``` | |||||
| ## Testing | |||||
| Backend tests: | |||||
| - Parse and validate `ModelDisplayPricing` JSON. | |||||
| - Default `discount_rate` to `1`. | |||||
| - Reject invalid model keys, empty specification, negative price, empty unit, and invalid discount rates. | |||||
| - Confirm `model.InitOptionMap()` initializes `ModelDisplayPricing` and `model.updateOptionMap()` hydrates the display pricing cache from DB-loaded option values. | |||||
| - Confirm `model.GetPricingByModel()` includes sorted `DisplayPricing`. | |||||
| - Confirm real `PricingConfig` behavior is unchanged when display pricing exists. | |||||
| - Confirm `/api/pricing/user/*model` returns `display_pricing` but does not use it for computed prices. | |||||
| - Confirm wildcard admin routes support model names containing `/`. | |||||
| Frontend tests: | |||||
| - `hasDisplayPricing()` detects display pricing before matrix pricing. | |||||
| - Summary renders min-max range with converted currency. | |||||
| - Detail table renders specification, supplier tip, unit, and discount. | |||||
| - Existing matrix and legacy pricing rendering remains unchanged when no display pricing exists. | |||||
| - User discount comparison is hidden when display pricing exists. | |||||
| Manual verification: | |||||
| - Configure one model with display pricing and token-based real billing. | |||||
| - Confirm public price page shows the display table. | |||||
| - Run a real task and confirm quota deduction follows the existing billing path, not `display_pricing.price`. | |||||
| @@ -134,6 +134,7 @@ func InitOptionMap() { | |||||
| common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() | common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() | ||||
| common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() | common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() | ||||
| common.OptionMap["ModelPricingRules"] = ratio_setting.ModelPricingRules2JSONString() | common.OptionMap["ModelPricingRules"] = ratio_setting.ModelPricingRules2JSONString() | ||||
| common.OptionMap["ModelDisplayPricing"] = ratio_setting.ModelDisplayPricing2JSONString() | |||||
| common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() | common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() | ||||
| common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() | common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() | ||||
| common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() | common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() | ||||
| @@ -514,6 +515,8 @@ func updateOptionMap(key string, value string) (err error) { | |||||
| err = ratio_setting.UpdateModelPriceByJSONString(value) | err = ratio_setting.UpdateModelPriceByJSONString(value) | ||||
| case "ModelPricingRules": | case "ModelPricingRules": | ||||
| err = ratio_setting.UpdateModelPricingRulesByJSONString(value) | err = ratio_setting.UpdateModelPricingRulesByJSONString(value) | ||||
| case "ModelDisplayPricing": | |||||
| err = ratio_setting.UpdateModelDisplayPricingByJSONString(value) | |||||
| case "CacheRatio": | case "CacheRatio": | ||||
| err = ratio_setting.UpdateCacheRatioByJSONString(value) | err = ratio_setting.UpdateCacheRatioByJSONString(value) | ||||
| case "CreateCacheRatio": | case "CreateCacheRatio": | ||||
| @@ -14,23 +14,24 @@ import ( | |||||
| ) | ) | ||||
| type Pricing struct { | type Pricing struct { | ||||
| ModelName string `json:"model_name"` | |||||
| Description string `json:"description,omitempty"` | |||||
| Icon string `json:"icon,omitempty"` | |||||
| Tags string `json:"tags,omitempty"` | |||||
| VendorID int `json:"vendor_id,omitempty"` | |||||
| QuotaType int `json:"quota_type"` | |||||
| ModelRatio float64 `json:"model_ratio"` | |||||
| ModelPrice float64 `json:"model_price"` | |||||
| OwnerBy string `json:"owner_by"` | |||||
| CompletionRatio float64 `json:"completion_ratio"` | |||||
| CacheRatio float64 `json:"cache_ratio"` | |||||
| CacheCreationRatio float64 `json:"cache_creation_ratio"` | |||||
| EnableGroup []string `json:"enable_groups"` | |||||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | |||||
| PricingVersion string `json:"pricing_version,omitempty"` | |||||
| Type int `json:"type"` | |||||
| PricingConfig *types.PricingConfig `json:"pricing_config,omitempty"` | |||||
| ModelName string `json:"model_name"` | |||||
| Description string `json:"description,omitempty"` | |||||
| Icon string `json:"icon,omitempty"` | |||||
| Tags string `json:"tags,omitempty"` | |||||
| VendorID int `json:"vendor_id,omitempty"` | |||||
| QuotaType int `json:"quota_type"` | |||||
| ModelRatio float64 `json:"model_ratio"` | |||||
| ModelPrice float64 `json:"model_price"` | |||||
| OwnerBy string `json:"owner_by"` | |||||
| CompletionRatio float64 `json:"completion_ratio"` | |||||
| CacheRatio float64 `json:"cache_ratio"` | |||||
| CacheCreationRatio float64 `json:"cache_creation_ratio"` | |||||
| EnableGroup []string `json:"enable_groups"` | |||||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | |||||
| PricingVersion string `json:"pricing_version,omitempty"` | |||||
| Type int `json:"type"` | |||||
| PricingConfig *types.PricingConfig `json:"pricing_config,omitempty"` | |||||
| DisplayPricing []types.ModelDisplayPricingItem `json:"display_pricing,omitempty"` | |||||
| } | } | ||||
| type PricingVendor struct { | type PricingVendor struct { | ||||
| @@ -340,6 +341,7 @@ func updatePricing() { | |||||
| } | } | ||||
| applyGlobalDefault(&pricing, model) | applyGlobalDefault(&pricing, model) | ||||
| applyDisplayPricing(&pricing, model) | |||||
| pricingMap = append(pricingMap, pricing) | pricingMap = append(pricingMap, pricing) | ||||
| } | } | ||||
| @@ -426,6 +428,13 @@ func applyMatrixPricingConfig(pricing *Pricing, model string) bool { | |||||
| return true | return true | ||||
| } | } | ||||
| func applyDisplayPricing(pricing *Pricing, model string) { | |||||
| items := ratio_setting.GetModelDisplayPricing(model) | |||||
| if len(items) > 0 { | |||||
| pricing.DisplayPricing = items | |||||
| } | |||||
| } | |||||
| func minPricingConfigPrice(cfg *types.PricingConfig) float64 { | func minPricingConfigPrice(cfg *types.PricingConfig) float64 { | ||||
| if cfg == nil || len(cfg.Table) == 0 { | if cfg == nil || len(cfg.Table) == 0 { | ||||
| return 0 | return 0 | ||||
| @@ -189,6 +189,41 @@ func TestGetPricingByModel_IncludesMatrixPricingConfig(t *testing.T) { | |||||
| require.Equal(t, []constant.EndpointType{constant.EndpointTypeDoubaoVideo}, pricing.SupportedEndpointTypes) | require.Equal(t, []constant.EndpointType{constant.EndpointTypeDoubaoVideo}, pricing.SupportedEndpointTypes) | ||||
| } | } | ||||
| func TestGetPricingByModelIncludesDisplayPricing(t *testing.T) { | |||||
| setupPricingTest(t) | |||||
| const displayModel = "display-pricing-model" | |||||
| require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+displayModel+`":10}`)) | |||||
| require.NoError(t, ratio_setting.SetModelDisplayPricing(displayModel, []types.ModelDisplayPricingItem{ | |||||
| {Specification: "768p-6s", Price: 0.33333, Unit: "second", SortOrder: 1}, | |||||
| })) | |||||
| require.NoError(t, (&Model{ | |||||
| ModelName: displayModel, | |||||
| Status: 1, | |||||
| }).Insert()) | |||||
| require.NoError(t, (&Channel{ | |||||
| Id: 1, | |||||
| Type: constant.ChannelTypeOpenAI, | |||||
| Key: "k", | |||||
| Name: "channel-a", | |||||
| Status: common.ChannelStatusEnabled, | |||||
| Models: displayModel, | |||||
| Group: "default", | |||||
| }).Insert()) | |||||
| RefreshPricing() | |||||
| pricing := GetPricingByModel(displayModel) | |||||
| require.NotNil(t, pricing) | |||||
| require.Len(t, pricing.DisplayPricing, 1) | |||||
| require.Equal(t, "768p-6s", pricing.DisplayPricing[0].Specification) | |||||
| require.Equal(t, QuotaTypeByTokens, pricing.QuotaType) | |||||
| require.Equal(t, 10.0, pricing.ModelRatio) | |||||
| require.Equal(t, 0.0, pricing.ModelPrice) | |||||
| require.Nil(t, pricing.PricingConfig) | |||||
| } | |||||
| func TestParseModelEndpointTypes_SupportsObjectAndArray(t *testing.T) { | func TestParseModelEndpointTypes_SupportsObjectAndArray(t *testing.T) { | ||||
| objectEndpoints := parseModelEndpointTypes(`{ | objectEndpoints := parseModelEndpointTypes(`{ | ||||
| "anthropic": {"path": "/v1/messages", "method": "POST"}, | "anthropic": {"path": "/v1/messages", "method": "POST"}, | ||||
| @@ -0,0 +1,361 @@ | |||||
| 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"] = canonicalAipingModelName(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 canonicalAipingModelName(modelName string) string { | |||||
| trimmed := strings.TrimSpace(modelName) | |||||
| switch strings.ToLower(trimmed) { | |||||
| case "kling-v1": | |||||
| return "Kling-V1" | |||||
| case "kling-v1-6": | |||||
| return "Kling-V1.6" | |||||
| case "kling-v2-6": | |||||
| return "Kling-V2.6" | |||||
| case "kling-v3": | |||||
| return "Kling-V3" | |||||
| case "kling-video-o1": | |||||
| return "Kling-Video-O1" | |||||
| case "kling-v3-omni": | |||||
| return "Kling-V3-Omni" | |||||
| default: | |||||
| return trimmed | |||||
| } | |||||
| } | |||||
| 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,206 @@ | |||||
| 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, "/api/v1/multimodal/kling/videos/text2video", route.UpstreamPath) | |||||
| voice, ok := FindRoute(http.MethodPost, "/v1/general/custom-voices", RouteKindSubmit) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, "/api/v1/multimodal/kling/general/custom-voices", voice.UpstreamPath) | |||||
| presets, ok := FindRoute(http.MethodGet, "/v1/general/presets-voices", RouteKindProxy) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, "/api/v1/multimodal/kling/general/presets-voices", presets.UpstreamPath) | |||||
| deleteVoices, ok := FindRoute(http.MethodPost, "/v1/general/delete-voices", RouteKindProxy) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, ActionDeleteVoices, deleteVoices.Action) | |||||
| deleteElements, ok := FindRoute(http.MethodPost, "/v1/general/delete-advanced-elements", RouteKindProxy) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, ActionDeleteElements, deleteElements.Action) | |||||
| videoExtend, ok := FindRoute(http.MethodPost, "/v1/videos/video-extend", RouteKindSubmit) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, ActionVideoExtend, videoExtend.Action) | |||||
| require.Equal(t, "/api/v1/multimodal/kling/videos/video-extend", videoExtend.UpstreamPath) | |||||
| motionControl, ok := FindRoute(http.MethodPost, "/v1/videos/motion-control", RouteKindSubmit) | |||||
| require.True(t, ok) | |||||
| require.Equal(t, "", motionControl.BillingModel, "motion-control BillingModel should be empty so client model_name is used") | |||||
| } | |||||
| func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) { | |||||
| adaptor := &TaskAdaptor{} | |||||
| info := &relaycommon.RelayInfo{ | |||||
| ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn"}, | |||||
| 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/multimodal/kling/videos/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, "/api/v1/multimodal/kling/videos/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 TestBuildRequestBodyMapsCanonicalModelNameForAiping(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| c, _ := gin.CreateTestContext(nil) | |||||
| info := &relaycommon.RelayInfo{ | |||||
| ChannelMeta: &relaycommon.ChannelMeta{}, | |||||
| TaskRelayInfo: &relaycommon.TaskRelayInfo{ | |||||
| Action: ActionText2Video, | |||||
| }, | |||||
| OriginModelName: "kling-v2-6", | |||||
| } | |||||
| relaycommon.StoreTaskRequest(c, info, ActionText2Video, relaycommon.TaskSubmitReq{ | |||||
| Model: "kling-v2-6", | |||||
| Metadata: map[string]any{ | |||||
| "model_name": "kling-v2-6", | |||||
| "prompt": "prompt", | |||||
| "duration": "5", | |||||
| }, | |||||
| }) | |||||
| 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"]) | |||||
| } | |||||
| 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,122 @@ | |||||
| package aiping | |||||
| import "net/http" | |||||
| const ( | |||||
| ActionText2Video = "kling-text2video" | |||||
| ActionImage2Video = "kling-image2video" | |||||
| ActionMotionControl = "kling-motion-control" | |||||
| ActionOmniVideo = "kling-omni-video" | |||||
| ActionMultiImage2Video = "kling-multi-image2video" | |||||
| ActionVideoExtend = "kling-video-extend" | |||||
| 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 ( | |||||
| 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, "/api/v1/multimodal/kling/videos/text2video", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/text2video/:task_id", ActionText2Video, "/api/v1/multimodal/kling/videos/text2video/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/text2video", ActionText2Video, "/api/v1/multimodal/kling/videos/text2video", RouteKindList, ""}, | |||||
| // 视频能力:图生视频 | |||||
| {http.MethodPost, "/v1/videos/image2video", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/image2video/:task_id", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/image2video", ActionImage2Video, "/api/v1/multimodal/kling/videos/image2video", RouteKindList, ""}, | |||||
| // 视频能力:动作控制(model_name 可指定 Kling-V3 / Kling-V2.6) | |||||
| {http.MethodPost, "/v1/videos/motion-control", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/motion-control/:task_id", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/motion-control", ActionMotionControl, "/api/v1/multimodal/kling/videos/motion-control", RouteKindList, ""}, | |||||
| // 视频能力:Omni / 多镜头 | |||||
| {http.MethodPost, "/v1/videos/omni-video", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/omni-video/:task_id", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/omni-video", ActionOmniVideo, "/api/v1/multimodal/kling/videos/omni-video", RouteKindList, ""}, | |||||
| // 视频能力:多图参考生视频 | |||||
| {http.MethodPost, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/multi-image2video/:task_id", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/api/v1/multimodal/kling/videos/multi-image2video", RouteKindList, ""}, | |||||
| // 视频能力:视频延长 | |||||
| {http.MethodPost, "/v1/videos/video-extend", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend", RouteKindSubmit, ""}, | |||||
| {http.MethodGet, "/v1/videos/video-extend/:task_id", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend/:task_id", RouteKindFetch, ""}, | |||||
| {http.MethodGet, "/v1/videos/video-extend", ActionVideoExtend, "/api/v1/multimodal/kling/videos/video-extend", RouteKindList, ""}, | |||||
| // 主体能力:自定义主体 | |||||
| {http.MethodPost, "/v1/general/advanced-custom-elements", ActionAdvancedElements, "/api/v1/multimodal/kling/general/advanced-custom-elements", RouteKindSubmit, ModelKlingAdvancedElements}, | |||||
| {http.MethodGet, "/v1/general/advanced-custom-elements/:task_id", ActionAdvancedElements, "/api/v1/multimodal/kling/general/advanced-custom-elements/:task_id", RouteKindFetch, ModelKlingAdvancedElements}, | |||||
| {http.MethodGet, "/v1/general/advanced-custom-elements", "kling-advanced-elements-list", "/api/v1/multimodal/kling/general/advanced-custom-elements", RouteKindProxy, ModelKlingAdvancedElements}, | |||||
| // 主体能力:官方主体列表 | |||||
| {http.MethodGet, "/v1/general/advanced-presets-elements", "kling-advanced-elements-presets", "/api/v1/multimodal/kling/general/advanced-presets-elements", RouteKindProxy, ModelKlingAdvancedElements}, | |||||
| // 主体能力:删除自定义主体 | |||||
| {http.MethodPost, "/v1/general/delete-advanced-elements", ActionDeleteElements, "/api/v1/multimodal/kling/general/delete-advanced-elements", RouteKindProxy, ModelKlingAdvancedElements}, | |||||
| // 音色能力:自定义音色 | |||||
| {http.MethodPost, "/v1/general/custom-voices", ActionVoicesCreate, "/api/v1/multimodal/kling/general/custom-voices", RouteKindSubmit, ModelCustomVoices}, | |||||
| {http.MethodGet, "/v1/general/custom-voices/:task_id", ActionVoicesCreate, "/api/v1/multimodal/kling/general/custom-voices/:task_id", RouteKindFetch, ModelCustomVoices}, | |||||
| {http.MethodGet, "/v1/general/custom-voices", "kling-voices-list", "/api/v1/multimodal/kling/general/custom-voices", RouteKindProxy, ModelCustomVoices}, | |||||
| // 音色能力:官方音色列表 | |||||
| {http.MethodGet, "/v1/general/presets-voices", "kling-voices-presets", "/api/v1/multimodal/kling/general/presets-voices", RouteKindProxy, ModelCustomVoices}, | |||||
| // 音色能力:删除自定义音色 | |||||
| {http.MethodPost, "/v1/general/delete-voices", ActionDeleteVoices, "/api/v1/multimodal/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", | |||||
| 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: | ||||
| @@ -192,9 +192,13 @@ func SetApiRouter(router *gin.Engine) { | |||||
| optionRoute.GET("/", controller.GetOptions) | optionRoute.GET("/", controller.GetOptions) | ||||
| optionRoute.PUT("/", controller.UpdateOption) | optionRoute.PUT("/", controller.UpdateOption) | ||||
| optionRoute.GET("/model_pricing", controller.GetModelPricingRules) | optionRoute.GET("/model_pricing", controller.GetModelPricingRules) | ||||
| optionRoute.GET("/model_pricing/:model", controller.GetModelPricingRule) | |||||
| optionRoute.PUT("/model_pricing/:model", controller.UpdateModelPricingRule) | |||||
| optionRoute.DELETE("/model_pricing/:model", controller.DeleteModelPricingRule) | |||||
| optionRoute.GET("/model_pricing/*model", controller.GetModelPricingRule) | |||||
| optionRoute.PUT("/model_pricing/*model", controller.UpdateModelPricingRule) | |||||
| optionRoute.DELETE("/model_pricing/*model", controller.DeleteModelPricingRule) | |||||
| optionRoute.GET("/model_display_pricing", controller.GetModelDisplayPricingRules) | |||||
| optionRoute.GET("/model_display_pricing/*model", controller.GetModelDisplayPricingRule) | |||||
| optionRoute.PUT("/model_display_pricing/*model", controller.UpdateModelDisplayPricingRule) | |||||
| optionRoute.DELETE("/model_display_pricing/*model", controller.DeleteModelDisplayPricingRule) | |||||
| optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) | optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) | ||||
| optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) | optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) | ||||
| optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) | optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) | ||||
| @@ -22,6 +22,40 @@ 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/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/video-extend", controller.KlingAipingNativeTaskSubmit) | |||||
| klingAipingRouter.GET("/videos/video-extend/:task_id", controller.KlingAipingNativeTaskFetch) | |||||
| klingAipingRouter.GET("/videos/video-extend", controller.KlingAipingNativeTaskList) | |||||
| klingAipingRouter.POST("/general/advanced-custom-elements", controller.KlingAipingNativeTaskSubmit) | |||||
| klingAipingRouter.GET("/general/advanced-custom-elements/:task_id", controller.KlingAipingNativeTaskFetch) | |||||
| klingAipingRouter.GET("/general/advanced-custom-elements", controller.KlingAipingNativeProxy) | |||||
| klingAipingRouter.GET("/general/advanced-presets-elements", controller.KlingAipingNativeProxy) | |||||
| klingAipingRouter.POST("/general/delete-advanced-elements", controller.KlingAipingNativeProxy) | |||||
| klingAipingRouter.POST("/general/custom-voices", controller.KlingAipingNativeTaskSubmit) | |||||
| klingAipingRouter.GET("/general/custom-voices/:task_id", controller.KlingAipingNativeTaskFetch) | |||||
| klingAipingRouter.GET("/general/custom-voices", controller.KlingAipingNativeProxy) | |||||
| klingAipingRouter.GET("/general/presets-voices", controller.KlingAipingNativeProxy) | |||||
| klingAipingRouter.POST("/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/video-extend", "KlingAipingNativeTaskSubmit") | |||||
| requireHandlerContains(t, r, http.MethodGet, "/v1/videos/video-extend/:task_id", "KlingAipingNativeTaskFetch") | |||||
| requireHandlerContains(t, r, http.MethodGet, "/v1/videos/video-extend", "KlingAipingNativeTaskList") | |||||
| requireHandlerContains(t, r, http.MethodGet, "/v1/general/advanced-presets-elements", "KlingAipingNativeProxy") | |||||
| requireHandlerContains(t, r, http.MethodPost, "/v1/general/delete-advanced-elements", "KlingAipingNativeProxy") | |||||
| requireHandlerContains(t, r, http.MethodPost, "/v1/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) | |||||
| } | |||||
| @@ -11,7 +11,7 @@ | |||||
| set -euo pipefail | set -euo pipefail | ||||
| DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" | DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" | ||||
| PROJECT_DIR="/mnt/d/code/new-api" | |||||
| PROJECT_DIR="${PROJECT_DIR:-/mnt/d/code/new-api}" | |||||
| CN_DIR="/root/new-api-cn" | CN_DIR="/root/new-api-cn" | ||||
| OV_DIR="/root/new-api-ov" | OV_DIR="/root/new-api-ov" | ||||
| @@ -0,0 +1,102 @@ | |||||
| package service | |||||
| import ( | |||||
| "errors" | |||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| const KlingAssetChannelType = constant.ChannelTypeKlingAiping | |||||
| func ResolveKlingAssetChannel(userId int, tokenGroup string) (*model.Channel, error) { | |||||
| channel, err := GetBoundKlingAssetChannel(userId, tokenGroup) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if channel != nil { | |||||
| return channel, nil | |||||
| } | |||||
| channel, err = AutoMatchKlingAssetChannel(tokenGroup) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if channel == nil { | |||||
| return nil, nil | |||||
| } | |||||
| if err = model.BindUserAssetChannel(userId, KlingAssetChannelType, tokenGroup, channel.Id); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return channel, nil | |||||
| } | |||||
| func GetBoundKlingAssetChannel(userId int, tokenGroup string) (*model.Channel, error) { | |||||
| binding, err := model.GetUserAssetChannel(userId, KlingAssetChannelType, tokenGroup) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if binding == nil { | |||||
| return nil, nil | |||||
| } | |||||
| channel, err := model.CacheGetChannel(binding.ChannelId) | |||||
| if err != nil { | |||||
| if errors.Is(err, gorm.ErrRecordNotFound) || common.MemoryCacheEnabled { | |||||
| return nil, nil | |||||
| } | |||||
| return nil, err | |||||
| } | |||||
| if !IsUsableKlingAssetChannel(channel, tokenGroup) { | |||||
| return nil, nil | |||||
| } | |||||
| return channel, nil | |||||
| } | |||||
| func AutoMatchKlingAssetChannel(tokenGroup string) (*model.Channel, error) { | |||||
| for startIdx := 0; ; startIdx += DoubaoAssetChannelPageSize { | |||||
| candidates, err := model.GetChannelsByType(startIdx, DoubaoAssetChannelPageSize, true, KlingAssetChannelType) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| for _, candidate := range candidates { | |||||
| if candidate == nil || !MatchDoubaoAssetGroup(candidate.GetGroups(), tokenGroup) { | |||||
| continue | |||||
| } | |||||
| channel, err := model.CacheGetChannel(candidate.Id) | |||||
| if err != nil { | |||||
| if errors.Is(err, gorm.ErrRecordNotFound) || common.MemoryCacheEnabled { | |||||
| continue | |||||
| } | |||||
| return nil, err | |||||
| } | |||||
| if IsUsableKlingAssetChannel(channel, tokenGroup) { | |||||
| return channel, nil | |||||
| } | |||||
| } | |||||
| if len(candidates) < DoubaoAssetChannelPageSize { | |||||
| return nil, nil | |||||
| } | |||||
| } | |||||
| } | |||||
| func IsUsableKlingAssetChannel(channel *model.Channel, tokenGroup string) bool { | |||||
| if channel == nil { | |||||
| return false | |||||
| } | |||||
| if channel.Type != KlingAssetChannelType { | |||||
| return false | |||||
| } | |||||
| if channel.Status != common.ChannelStatusEnabled { | |||||
| return false | |||||
| } | |||||
| if strings.TrimSpace(channel.Key) == "" || len(channel.GetKeys()) == 0 { | |||||
| return false | |||||
| } | |||||
| return MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| package service | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | |||||
| ) | |||||
| func TestResolveKlingAssetChannel_PrefersBoundChannel(t *testing.T) { | |||||
| db := setupDoubaoAssetChannelDB(t) | |||||
| createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "bound-key", common.ChannelStatusEnabled) | |||||
| createDoubaoAssetChannelForTest(t, db, 2, constant.ChannelTypeKlingAiping, "default", "auto-key", common.ChannelStatusEnabled) | |||||
| require.NoError(t, model.BindUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default", 1)) | |||||
| channel, err := ResolveKlingAssetChannel(10, "default") | |||||
| require.NoError(t, err) | |||||
| require.NotNil(t, channel) | |||||
| assert.Equal(t, 1, channel.Id) | |||||
| assert.Equal(t, "bound-key", channel.Key) | |||||
| } | |||||
| func TestResolveKlingAssetChannel_AutoMatchesAndPersistsWhenUnbound(t *testing.T) { | |||||
| db := setupDoubaoAssetChannelDB(t) | |||||
| createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "auto-key", common.ChannelStatusEnabled) | |||||
| channel, err := ResolveKlingAssetChannel(10, "default") | |||||
| require.NoError(t, err) | |||||
| require.NotNil(t, channel) | |||||
| assert.Equal(t, 1, channel.Id) | |||||
| binding, err := model.GetUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default") | |||||
| require.NoError(t, err) | |||||
| require.NotNil(t, binding) | |||||
| assert.Equal(t, 1, binding.ChannelId) | |||||
| } | |||||
| func TestResolveKlingAssetChannel_IgnoresDisabledChannel(t *testing.T) { | |||||
| db := setupDoubaoAssetChannelDB(t) | |||||
| createDoubaoAssetChannelForTest(t, db, 1, constant.ChannelTypeKlingAiping, "default", "disabled-key", common.ChannelStatusAutoDisabled) | |||||
| createDoubaoAssetChannelForTest(t, db, 2, constant.ChannelTypeKlingAiping, "default", "enabled-key", common.ChannelStatusEnabled) | |||||
| require.NoError(t, model.BindUserAssetChannel(10, constant.ChannelTypeKlingAiping, "default", 1)) | |||||
| channel, err := ResolveKlingAssetChannel(10, "default") | |||||
| require.NoError(t, err) | |||||
| require.NotNil(t, channel) | |||||
| assert.Equal(t, 2, channel.Id) | |||||
| } | |||||
| func TestIsUsableKlingAssetChannel_RejectsOtherChannelType(t *testing.T) { | |||||
| ch := &model.Channel{Type: constant.ChannelTypeDoubaoVideoCompatibleAiping, Key: "k", Status: common.ChannelStatusEnabled, Group: "default"} | |||||
| assert.False(t, IsUsableKlingAssetChannel(ch, "default")) | |||||
| } | |||||
| func TestIsUsableKlingAssetChannel_RejectsGroupMismatch(t *testing.T) { | |||||
| ch := &model.Channel{Type: constant.ChannelTypeKlingAiping, Key: "k", Status: common.ChannelStatusEnabled, Group: "vip"} | |||||
| assert.False(t, IsUsableKlingAssetChannel(ch, "default")) | |||||
| } | |||||
| @@ -3,13 +3,17 @@ package service | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "io" | |||||
| "net/http" | "net/http" | ||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "os" | "os" | ||||
| "strings" | |||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/dto" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | relaycommon "github.com/QuantumNous/new-api/relay/common" | ||||
| "github.com/QuantumNous/new-api/types" | "github.com/QuantumNous/new-api/types" | ||||
| @@ -666,6 +670,76 @@ func TestCASGuardedSettle_Win(t *testing.T) { | |||||
| assert.Equal(t, actualQuota, task.Quota) | assert.Equal(t, actualQuota, task.Quota) | ||||
| } | } | ||||
| func TestUpdateVideoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) { | |||||
| truncate(t) | |||||
| ctx := context.Background() | |||||
| const userID, tokenID, channelID = 24, 24, 424242 | |||||
| const initQuota, preConsumed = 10000, 3500 | |||||
| const tokenRemain = 7000 | |||||
| seedUser(t, userID, initQuota) | |||||
| seedToken(t, tokenID, userID, "sk-missing-channel-refund", tokenRemain) | |||||
| task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) | |||||
| task.Status = model.TaskStatus(model.TaskStatusInProgress) | |||||
| task.Progress = "50%" | |||||
| require.NoError(t, model.DB.Create(task).Error) | |||||
| err := UpdateVideoTasks(ctx, "missing-channel-platform", map[int][]string{ | |||||
| channelID: {task.GetUpstreamTaskID()}, | |||||
| }, map[string]*model.Task{ | |||||
| task.GetUpstreamTaskID(): task, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| var reloaded model.Task | |||||
| require.NoError(t, model.DB.First(&reloaded, task.ID).Error) | |||||
| assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status) | |||||
| assert.Equal(t, "100%", reloaded.Progress) | |||||
| assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID)) | |||||
| assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID)) | |||||
| log := getLastLog(t) | |||||
| require.NotNil(t, log) | |||||
| assert.Equal(t, model.LogTypeRefund, log.Type) | |||||
| } | |||||
| func TestUpdateSunoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) { | |||||
| truncate(t) | |||||
| ctx := context.Background() | |||||
| const userID, tokenID, channelID = 25, 25, 525252 | |||||
| const initQuota, preConsumed = 10000, 2500 | |||||
| const tokenRemain = 6000 | |||||
| seedUser(t, userID, initQuota) | |||||
| seedToken(t, tokenID, userID, "sk-suno-missing-channel-refund", tokenRemain) | |||||
| task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) | |||||
| task.Status = model.TaskStatus(model.TaskStatusInProgress) | |||||
| task.Progress = "50%" | |||||
| require.NoError(t, model.DB.Create(task).Error) | |||||
| err := UpdateSunoTasks(ctx, map[int][]string{ | |||||
| channelID: {task.GetUpstreamTaskID()}, | |||||
| }, map[string]*model.Task{ | |||||
| task.GetUpstreamTaskID(): task, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| var reloaded model.Task | |||||
| require.NoError(t, model.DB.First(&reloaded, task.ID).Error) | |||||
| assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status) | |||||
| assert.Equal(t, "100%", reloaded.Progress) | |||||
| assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID)) | |||||
| assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID)) | |||||
| log := getLastLog(t) | |||||
| require.NotNil(t, log) | |||||
| assert.Equal(t, model.LogTypeRefund, log.Type) | |||||
| } | |||||
| func TestNonTerminalUpdate_NoBilling(t *testing.T) { | func TestNonTerminalUpdate_NoBilling(t *testing.T) { | ||||
| truncate(t) | truncate(t) | ||||
| ctx := context.Background() | ctx := context.Background() | ||||
| @@ -713,6 +787,74 @@ func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.Task | |||||
| return m.adjustReturn | return m.adjustReturn | ||||
| } | } | ||||
| type mockSunoPollingAdaptor struct { | |||||
| mockAdaptor | |||||
| body string | |||||
| } | |||||
| func (m *mockSunoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { | |||||
| return &http.Response{ | |||||
| StatusCode: http.StatusOK, | |||||
| Body: io.NopCloser(strings.NewReader(m.body)), | |||||
| }, nil | |||||
| } | |||||
| func TestUpdateSunoTasks_CASLostDoesNotRefundAgain(t *testing.T) { | |||||
| truncate(t) | |||||
| ctx := context.Background() | |||||
| const userID, tokenID, channelID = 26, 26, 26 | |||||
| const initQuota, preConsumed = 10000, 2000 | |||||
| const tokenRemain = 5000 | |||||
| seedUser(t, userID, initQuota) | |||||
| seedToken(t, tokenID, userID, "sk-suno-cas-lost", tokenRemain) | |||||
| seedChannel(t, channelID) | |||||
| task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) | |||||
| task.TaskID = "task_suno_cas_lost" | |||||
| task.PrivateData.UpstreamTaskID = "suno_upstream_cas_lost" | |||||
| task.Status = model.TaskStatus(model.TaskStatusInProgress) | |||||
| task.Progress = "50%" | |||||
| require.NoError(t, model.DB.Create(task).Error) | |||||
| // Another poller already transitioned the task. This stale in-memory task must not refund again. | |||||
| require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{ | |||||
| "status": model.TaskStatusFailure, | |||||
| "progress": "100%", | |||||
| "fail_reason": "already failed", | |||||
| }).Error) | |||||
| body, err := common.Marshal(dto.TaskResponse[[]dto.SunoDataResponse]{ | |||||
| Code: dto.TaskSuccessCode, | |||||
| Data: []dto.SunoDataResponse{{ | |||||
| TaskID: task.GetUpstreamTaskID(), | |||||
| Status: model.TaskStatusFailure, | |||||
| FailReason: "upstream failed", | |||||
| Data: json.RawMessage(`{}`), | |||||
| }}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| oldGetTaskAdaptor := GetTaskAdaptorFunc | |||||
| GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor { | |||||
| require.Equal(t, constant.TaskPlatformSuno, platform) | |||||
| return &mockSunoPollingAdaptor{body: string(body)} | |||||
| } | |||||
| t.Cleanup(func() { GetTaskAdaptorFunc = oldGetTaskAdaptor }) | |||||
| err = UpdateSunoTasks(ctx, map[int][]string{ | |||||
| channelID: {task.GetUpstreamTaskID()}, | |||||
| }, map[string]*model.Task{ | |||||
| task.GetUpstreamTaskID(): task, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| assert.Equal(t, initQuota, getUserQuota(t, userID)) | |||||
| assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID)) | |||||
| assert.Equal(t, int64(0), countLogs(t)) | |||||
| } | |||||
| // =========================================================================== | // =========================================================================== | ||||
| // PerCallBilling tests — settleTaskBillingOnComplete | // PerCallBilling tests — settleTaskBillingOnComplete | ||||
| // =========================================================================== | // =========================================================================== | ||||
| @@ -152,6 +152,42 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int | |||||
| } | } | ||||
| } | } | ||||
| func failTaskWithRefund(ctx context.Context, task *model.Task, reason string, finishTime int64, fromStatus ...model.TaskStatus) { | |||||
| if task == nil { | |||||
| return | |||||
| } | |||||
| oldStatus := task.Status | |||||
| if len(fromStatus) > 0 { | |||||
| oldStatus = fromStatus[0] | |||||
| } | |||||
| task.Status = model.TaskStatusFailure | |||||
| task.Progress = taskcommon.ProgressComplete | |||||
| task.FinishTime = finishTime | |||||
| task.FailReason = reason | |||||
| won, err := task.UpdateWithStatus(oldStatus) | |||||
| if err != nil { | |||||
| logger.LogError(ctx, fmt.Sprintf("failTaskWithRefund CAS update error for task %s: %v", task.TaskID, err)) | |||||
| return | |||||
| } | |||||
| if !won { | |||||
| logger.LogInfo(ctx, fmt.Sprintf("failTaskWithRefund: task %s already transitioned, skip refund", task.TaskID)) | |||||
| return | |||||
| } | |||||
| if task.Quota != 0 { | |||||
| RefundTaskQuota(ctx, task, reason) | |||||
| } | |||||
| } | |||||
| func failTasksForChannel(ctx context.Context, taskIds []string, taskM map[string]*model.Task, reason string) { | |||||
| now := time.Now().Unix() | |||||
| for _, upstreamID := range taskIds { | |||||
| if t, ok := taskM[upstreamID]; ok { | |||||
| failTaskWithRefund(ctx, t, reason, now) | |||||
| } | |||||
| } | |||||
| } | |||||
| // UpdateSunoTasks 按渠道更新所有 Suno 任务 | // UpdateSunoTasks 按渠道更新所有 Suno 任务 | ||||
| func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { | func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { | ||||
| for channelId, taskIds := range taskChannelM { | for channelId, taskIds := range taskChannelM { | ||||
| @@ -171,22 +207,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM | |||||
| ch, err := model.CacheGetChannel(channelId) | ch, err := model.CacheGetChannel(channelId) | ||||
| if err != nil { | if err != nil { | ||||
| common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err)) | common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err)) | ||||
| // Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values) | |||||
| var failedIDs []int64 | |||||
| for _, upstreamID := range taskIds { | |||||
| if t, ok := taskM[upstreamID]; ok { | |||||
| failedIDs = append(failedIDs, t.ID) | |||||
| } | |||||
| } | |||||
| err = model.TaskBulkUpdateByID(failedIDs, map[string]any{ | |||||
| "fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId), | |||||
| "status": "FAILURE", | |||||
| "progress": "100%", | |||||
| }) | |||||
| if err != nil { | |||||
| common.SysLog(fmt.Sprintf("UpdateSunoTask error: %v", err)) | |||||
| } | |||||
| return err | |||||
| failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId)) | |||||
| return fmt.Errorf("CacheGetChannel failed: %w", err) | |||||
| } | } | ||||
| adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno) | adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno) | ||||
| if adaptor == nil { | if adaptor == nil { | ||||
| @@ -227,6 +249,7 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM | |||||
| continue | continue | ||||
| } | } | ||||
| oldStatus := task.Status | |||||
| task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status) | task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status) | ||||
| task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason) | task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason) | ||||
| task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime) | task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime) | ||||
| @@ -234,8 +257,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM | |||||
| task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime) | task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime) | ||||
| if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { | if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { | ||||
| logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) | logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) | ||||
| task.Progress = "100%" | |||||
| RefundTaskQuota(ctx, task, task.FailReason) | |||||
| failTaskWithRefund(ctx, task, task.FailReason, task.FinishTime, oldStatus) | |||||
| continue | |||||
| } | } | ||||
| if responseItem.Status == model.TaskStatusSuccess { | if responseItem.Status == model.TaskStatusSuccess { | ||||
| task.Progress = "100%" | task.Progress = "100%" | ||||
| @@ -305,21 +328,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann | |||||
| } | } | ||||
| cacheGetChannel, err := model.CacheGetChannel(channelId) | cacheGetChannel, err := model.CacheGetChannel(channelId) | ||||
| if err != nil { | if err != nil { | ||||
| // Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values) | |||||
| var failedIDs []int64 | |||||
| for _, upstreamID := range taskIds { | |||||
| if t, ok := taskM[upstreamID]; ok { | |||||
| failedIDs = append(failedIDs, t.ID) | |||||
| } | |||||
| } | |||||
| errUpdate := model.TaskBulkUpdateByID(failedIDs, map[string]any{ | |||||
| "fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId), | |||||
| "status": "FAILURE", | |||||
| "progress": "100%", | |||||
| }) | |||||
| if errUpdate != nil { | |||||
| common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate)) | |||||
| } | |||||
| failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId)) | |||||
| return fmt.Errorf("CacheGetChannel failed: %w", err) | return fmt.Errorf("CacheGetChannel failed: %w", err) | ||||
| } | } | ||||
| adaptor := GetTaskAdaptorFunc(platform) | adaptor := GetTaskAdaptorFunc(platform) | ||||
| @@ -0,0 +1,120 @@ | |||||
| package ratio_setting | |||||
| import ( | |||||
| "fmt" | |||||
| "sort" | |||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| ) | |||||
| const ModelDisplayPricingOptionKey = "ModelDisplayPricing" | |||||
| var displayPricingMap = types.NewRWMap[string, []types.ModelDisplayPricingItem]() | |||||
| func GetModelDisplayPricing(model string) []types.ModelDisplayPricingItem { | |||||
| items, ok := displayPricingMap.Get(model) | |||||
| if !ok { | |||||
| return nil | |||||
| } | |||||
| return types.CloneModelDisplayPricingItems(items) | |||||
| } | |||||
| func GetModelDisplayPricingCopy() map[string][]types.ModelDisplayPricingItem { | |||||
| src := displayPricingMap.ReadAll() | |||||
| dst := make(map[string][]types.ModelDisplayPricingItem, len(src)) | |||||
| for model, items := range src { | |||||
| dst[model] = types.CloneModelDisplayPricingItems(items) | |||||
| } | |||||
| return dst | |||||
| } | |||||
| func SetModelDisplayPricing(model string, items []types.ModelDisplayPricingItem) error { | |||||
| model = strings.TrimSpace(model) | |||||
| if model == "" { | |||||
| return fmt.Errorf("model name is required") | |||||
| } | |||||
| normalized, err := normalizeModelDisplayPricingItems(items) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| displayPricingMap.Set(model, normalized) | |||||
| InvalidateExposedDataCache() | |||||
| return nil | |||||
| } | |||||
| func DeleteModelDisplayPricing(model string) error { | |||||
| all := displayPricingMap.ReadAll() | |||||
| delete(all, model) | |||||
| displayPricingMap.Clear() | |||||
| displayPricingMap.AddAll(all) | |||||
| InvalidateExposedDataCache() | |||||
| return nil | |||||
| } | |||||
| func ModelDisplayPricing2JSONString() string { | |||||
| bytes, err := common.Marshal(GetModelDisplayPricingCopy()) | |||||
| if err != nil { | |||||
| return "{}" | |||||
| } | |||||
| return string(bytes) | |||||
| } | |||||
| func UpdateModelDisplayPricingByJSONString(jsonStr string) error { | |||||
| if strings.TrimSpace(jsonStr) == "" { | |||||
| jsonStr = "{}" | |||||
| } | |||||
| raw := map[string][]types.ModelDisplayPricingItem{} | |||||
| if err := common.UnmarshalJsonStr(jsonStr, &raw); err != nil { | |||||
| return err | |||||
| } | |||||
| next := make(map[string][]types.ModelDisplayPricingItem, len(raw)) | |||||
| for model, items := range raw { | |||||
| model = strings.TrimSpace(model) | |||||
| if model == "" { | |||||
| return fmt.Errorf("model name is required") | |||||
| } | |||||
| normalized, err := normalizeModelDisplayPricingItems(items) | |||||
| if err != nil { | |||||
| return fmt.Errorf("model %s: %w", model, err) | |||||
| } | |||||
| next[model] = normalized | |||||
| } | |||||
| displayPricingMap.Clear() | |||||
| displayPricingMap.AddAll(next) | |||||
| InvalidateExposedDataCache() | |||||
| return nil | |||||
| } | |||||
| func normalizeModelDisplayPricingItems(items []types.ModelDisplayPricingItem) ([]types.ModelDisplayPricingItem, error) { | |||||
| normalized := types.CloneModelDisplayPricingItems(items) | |||||
| for i := range normalized { | |||||
| item := &normalized[i] | |||||
| item.Specification = strings.TrimSpace(item.Specification) | |||||
| item.OfficialSupplierTip = strings.TrimSpace(item.OfficialSupplierTip) | |||||
| item.Unit = strings.TrimSpace(item.Unit) | |||||
| if item.Specification == "" { | |||||
| return nil, fmt.Errorf("row %d: specification is required", i) | |||||
| } | |||||
| if item.Price < 0 { | |||||
| return nil, fmt.Errorf("row %d: price must be >= 0", i) | |||||
| } | |||||
| if item.Unit == "" { | |||||
| return nil, fmt.Errorf("row %d: unit is required", i) | |||||
| } | |||||
| if item.DiscountRate == 0 { | |||||
| item.DiscountRate = 1 | |||||
| } | |||||
| if item.DiscountRate <= 0 || item.DiscountRate > 1 { | |||||
| return nil, fmt.Errorf("row %d: discount_rate must be > 0 and <= 1", i) | |||||
| } | |||||
| } | |||||
| sort.SliceStable(normalized, func(i, j int) bool { | |||||
| if normalized[i].SortOrder != normalized[j].SortOrder { | |||||
| return normalized[i].SortOrder < normalized[j].SortOrder | |||||
| } | |||||
| return normalized[i].Specification < normalized[j].Specification | |||||
| }) | |||||
| return normalized, nil | |||||
| } | |||||
| @@ -0,0 +1,95 @@ | |||||
| package ratio_setting | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/stretchr/testify/require" | |||||
| ) | |||||
| func TestModelDisplayPricingRoundTripNormalizeAndSort(t *testing.T) { | |||||
| require.NoError(t, UpdateModelDisplayPricingByJSONString(`{}`)) | |||||
| jsonStr := `{ | |||||
| "kling-video": [ | |||||
| {"specification":"1080p","official_supplier_tip":"1080P","price":0.58333,"unit":"second","sort_order":3}, | |||||
| {"specification":"768p-6s","official_supplier_tip":"768P 6s","price":0.33333,"unit":"second","sort_order":1,"discount_rate":0.8}, | |||||
| {"specification":"768p-10s","official_supplier_tip":"768P 10s","price":0.4,"unit":"second","sort_order":2,"discount_rate":0} | |||||
| ] | |||||
| }` | |||||
| require.NoError(t, UpdateModelDisplayPricingByJSONString(jsonStr)) | |||||
| items := GetModelDisplayPricing("kling-video") | |||||
| require.Len(t, items, 3) | |||||
| require.Equal(t, "768p-6s", items[0].Specification) | |||||
| require.Equal(t, 0.8, items[0].DiscountRate) | |||||
| require.Equal(t, "768p-10s", items[1].Specification) | |||||
| require.Equal(t, 1.0, items[1].DiscountRate) | |||||
| require.Equal(t, "1080p", items[2].Specification) | |||||
| require.Equal(t, 1.0, items[2].DiscountRate) | |||||
| items[0].Specification = "mutated" | |||||
| require.Equal(t, "768p-6s", GetModelDisplayPricing("kling-video")[0].Specification) | |||||
| roundTrip := ModelDisplayPricing2JSONString() | |||||
| require.NoError(t, UpdateModelDisplayPricingByJSONString(roundTrip)) | |||||
| require.Equal(t, "768p-6s", GetModelDisplayPricing("kling-video")[0].Specification) | |||||
| } | |||||
| func TestSetModelDisplayPricingValidation(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| model string | |||||
| items []types.ModelDisplayPricingItem | |||||
| want string | |||||
| }{ | |||||
| { | |||||
| name: "empty_model", | |||||
| model: "", | |||||
| items: []types.ModelDisplayPricingItem{{Specification: "768p", Price: 1, Unit: "second"}}, | |||||
| want: "model name is required", | |||||
| }, | |||||
| { | |||||
| name: "empty_specification", | |||||
| model: "kling-video", | |||||
| items: []types.ModelDisplayPricingItem{{Price: 1, Unit: "second"}}, | |||||
| want: "specification is required", | |||||
| }, | |||||
| { | |||||
| name: "negative_price", | |||||
| model: "kling-video", | |||||
| items: []types.ModelDisplayPricingItem{{Specification: "768p", Price: -1, Unit: "second"}}, | |||||
| want: "price must be >= 0", | |||||
| }, | |||||
| { | |||||
| name: "empty_unit", | |||||
| model: "kling-video", | |||||
| items: []types.ModelDisplayPricingItem{{Specification: "768p", Price: 1}}, | |||||
| want: "unit is required", | |||||
| }, | |||||
| { | |||||
| name: "bad_discount", | |||||
| model: "kling-video", | |||||
| items: []types.ModelDisplayPricingItem{{Specification: "768p", Price: 1, Unit: "second", DiscountRate: 1.2}}, | |||||
| want: "discount_rate must be > 0 and <= 1", | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| err := SetModelDisplayPricing(tt.model, tt.items) | |||||
| require.Error(t, err) | |||||
| require.Contains(t, err.Error(), tt.want) | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestDeleteModelDisplayPricing(t *testing.T) { | |||||
| require.NoError(t, UpdateModelDisplayPricingByJSONString(`{}`)) | |||||
| require.NoError(t, SetModelDisplayPricing("kling-video", []types.ModelDisplayPricingItem{ | |||||
| {Specification: "768p", Price: 1, Unit: "second"}, | |||||
| })) | |||||
| require.Len(t, GetModelDisplayPricing("kling-video"), 1) | |||||
| require.NoError(t, DeleteModelDisplayPricing("kling-video")) | |||||
| require.Empty(t, GetModelDisplayPricing("kling-video")) | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| package types | |||||
| type ModelDisplayPricingItem struct { | |||||
| Specification string `json:"specification"` | |||||
| OfficialSupplierTip string `json:"official_supplier_tip"` | |||||
| Price float64 `json:"price"` | |||||
| Unit string `json:"unit"` | |||||
| SortOrder int `json:"sort_order"` | |||||
| DiscountRate float64 `json:"discount_rate"` | |||||
| } | |||||
| func CloneModelDisplayPricingItems(src []ModelDisplayPricingItem) []ModelDisplayPricingItem { | |||||
| if src == nil { | |||||
| return nil | |||||
| } | |||||
| dst := make([]ModelDisplayPricingItem, len(src)) | |||||
| copy(dst, src) | |||||
| return dst | |||||
| } | |||||
| @@ -22,9 +22,12 @@ import { Card, Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui'; | |||||
| import { IconCoinMoneyStroked } from '@douyinfe/semi-icons'; | import { IconCoinMoneyStroked } from '@douyinfe/semi-icons'; | ||||
| import { | import { | ||||
| calculateModelPrice, | calculateModelPrice, | ||||
| formatDisplayPricingItem, | |||||
| formatMatrixPrice, | formatMatrixPrice, | ||||
| getMatrixPricingUnit, | getMatrixPricingUnit, | ||||
| hasDisplayPricing, | |||||
| hasMatrixPricing, | hasMatrixPricing, | ||||
| normalizeDisplayPricingItems, | |||||
| } from '../../../../../helpers'; | } from '../../../../../helpers'; | ||||
| const { Text } = Typography; | const { Text } = Typography; | ||||
| @@ -44,6 +47,62 @@ const ModelPricingTable = ({ | |||||
| ? modelData.enable_groups | ? modelData.enable_groups | ||||
| : []; | : []; | ||||
| const autoChain = autoGroups.filter((g) => modelEnableGroups.includes(g)); | const autoChain = autoGroups.filter((g) => modelEnableGroups.includes(g)); | ||||
| const renderDisplayPricingTable = () => { | |||||
| const items = normalizeDisplayPricingItems(modelData).map((item) => | |||||
| formatDisplayPricingItem({ item, displayPrice, t }), | |||||
| ); | |||||
| const columns = [ | |||||
| { | |||||
| title: t('规格'), | |||||
| dataIndex: 'specification', | |||||
| render: (text) => <span className='font-semibold'>{text}</span>, | |||||
| }, | |||||
| { | |||||
| title: t('供应商说明'), | |||||
| dataIndex: 'official_supplier_tip', | |||||
| render: (text) => <span className='text-gray-600'>{text || '-'}</span>, | |||||
| }, | |||||
| { | |||||
| title: t('价格'), | |||||
| dataIndex: 'formattedPrice', | |||||
| render: (text, record) => ( | |||||
| <div> | |||||
| <div className='font-semibold text-orange-600'>{text}</div> | |||||
| {record.originalPrice && ( | |||||
| <div className='text-xs text-gray-400 line-through'> | |||||
| {t('原价')} {record.originalPrice} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('折扣'), | |||||
| dataIndex: 'discountText', | |||||
| render: (text) => text || '-', | |||||
| }, | |||||
| ]; | |||||
| return ( | |||||
| <> | |||||
| <div className='mb-3 rounded-lg border border-orange-100 bg-orange-50 px-3 py-2 text-xs text-orange-700'> | |||||
| {t('展示价格仅用于估算,实际消耗以任务完成后的积分扣减为准。')} | |||||
| </div> | |||||
| <Table | |||||
| dataSource={items.map((item, index) => ({ | |||||
| ...item, | |||||
| key: `${item.specification}-${index}`, | |||||
| }))} | |||||
| columns={columns} | |||||
| pagination={false} | |||||
| size='small' | |||||
| bordered={false} | |||||
| className='!rounded-lg' | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| const renderMatrixPriceTable = () => { | const renderMatrixPriceTable = () => { | ||||
| const cfg = modelData?.pricing_config; | const cfg = modelData?.pricing_config; | ||||
| const dimensions = cfg?.dimensions || []; | const dimensions = cfg?.dimensions || []; | ||||
| @@ -124,6 +183,9 @@ const ModelPricingTable = ({ | |||||
| }; | }; | ||||
| const renderGroupPriceTable = () => { | const renderGroupPriceTable = () => { | ||||
| if (hasDisplayPricing(modelData)) { | |||||
| return renderDisplayPricingTable(); | |||||
| } | |||||
| if (hasMatrixPricing(modelData)) { | if (hasMatrixPricing(modelData)) { | ||||
| return renderMatrixPriceTable(); | return renderMatrixPriceTable(); | ||||
| } | } | ||||
| @@ -144,6 +206,7 @@ const ModelPricingTable = ({ | |||||
| tokenUnit, | tokenUnit, | ||||
| displayPrice, | displayPrice, | ||||
| currency, | currency, | ||||
| t, | |||||
| }) | }) | ||||
| : { inputPrice: '-', outputPrice: '-', price: '-' }; | : { inputPrice: '-', outputPrice: '-', price: '-' }; | ||||
| @@ -322,9 +385,13 @@ const ModelPricingTable = ({ | |||||
| <IconCoinMoneyStroked size={16} /> | <IconCoinMoneyStroked size={16} /> | ||||
| </Avatar> | </Avatar> | ||||
| <div> | <div> | ||||
| <Text className='text-lg font-medium'>{t('分组价格')}</Text> | |||||
| <Text className='text-lg font-medium'> | |||||
| {hasDisplayPricing(modelData) ? t('展示价格') : t('分组价格')} | |||||
| </Text> | |||||
| <div className='text-xs text-gray-600'> | <div className='text-xs text-gray-600'> | ||||
| {t('不同用户分组的价格信息')} | |||||
| {hasDisplayPricing(modelData) | |||||
| ? t('展示价格,仅用于估算') | |||||
| : t('不同用户分组的价格信息')} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -7,17 +7,22 @@ const { Text } = Typography; | |||||
| const UserPriceComparison = ({ | const UserPriceComparison = ({ | ||||
| modelName, | modelName, | ||||
| modelData, | |||||
| currency, | currency, | ||||
| tokenUnit, | tokenUnit, | ||||
| displayPrice, | displayPrice, | ||||
| t, | t, | ||||
| }) => { | }) => { | ||||
| const hasDisplayPricing = | |||||
| Array.isArray(modelData?.display_pricing) && | |||||
| modelData.display_pricing.length > 0; | |||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [priceData, setPriceData] = useState(null); | const [priceData, setPriceData] = useState(null); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| const fetchUserPrice = async () => { | const fetchUserPrice = async () => { | ||||
| if (!modelName) return; | |||||
| if (!modelName || hasDisplayPricing) return; | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const res = await API.get( | const res = await API.get( | ||||
| @@ -37,7 +42,7 @@ const UserPriceComparison = ({ | |||||
| } | } | ||||
| }; | }; | ||||
| fetchUserPrice(); | fetchUserPrice(); | ||||
| }, [modelName]); | |||||
| }, [modelName, hasDisplayPricing]); | |||||
| const currencySymbol = useMemo(() => { | const currencySymbol = useMemo(() => { | ||||
| if (currency === 'CNY') return '¥'; | if (currency === 'CNY') return '¥'; | ||||
| @@ -74,6 +79,8 @@ const UserPriceComparison = ({ | |||||
| return `${currencySymbol}${num.toFixed(4)}`; | return `${currencySymbol}${num.toFixed(4)}`; | ||||
| }; | }; | ||||
| if (hasDisplayPricing) return null; | |||||
| if (loading) { | if (loading) { | ||||
| return ( | return ( | ||||
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> | <Card className='!rounded-2xl shadow-sm border-0 mb-4'> | ||||
| @@ -38,6 +38,7 @@ import { | |||||
| import { | import { | ||||
| stringToColor, | stringToColor, | ||||
| getLobeHubIcon, | getLobeHubIcon, | ||||
| hasDisplayPricing, | |||||
| hasMatrixPricing, | hasMatrixPricing, | ||||
| calculateModelPrice, | calculateModelPrice, | ||||
| } from '../../../../../helpers'; | } from '../../../../../helpers'; | ||||
| @@ -163,8 +164,41 @@ const PricingCardView = ({ | |||||
| tokenUnit, | tokenUnit, | ||||
| displayPrice, | displayPrice, | ||||
| currency, | currency, | ||||
| t, | |||||
| }); | }); | ||||
| if (priceData?.isDisplayPricing) { | |||||
| return ( | |||||
| <div className='mb-3 text-xs'> | |||||
| {String(priceData.price || '') | |||||
| .split('\n') | |||||
| .map((line) => ( | |||||
| <div key={line} className='font-semibold text-gray-900'> | |||||
| {line} | |||||
| </div> | |||||
| ))} | |||||
| <div className='mt-1 text-gray-500'>{t('展示价格')}</div> | |||||
| {(priceData.details || []).map((detail, detailIndex) => ( | |||||
| <div | |||||
| key={`${detail.specification}-${detailIndex}`} | |||||
| className='mt-1 flex items-center justify-between gap-2 text-gray-500' | |||||
| title={`${detail.specification} ${detail.formattedPrice}`} | |||||
| > | |||||
| <span className='min-w-0 truncate'>{detail.specification}</span> | |||||
| <span className='shrink-0 text-gray-700'> | |||||
| {detail.formattedPrice} | |||||
| </span> | |||||
| </div> | |||||
| ))} | |||||
| {priceData.hiddenDetailCount > 0 && ( | |||||
| <div className='mt-1 text-gray-400'> | |||||
| + {priceData.hiddenDetailCount} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| if (!priceData?.isMatrix) { | if (!priceData?.isMatrix) { | ||||
| return null; | return null; | ||||
| } | } | ||||
| @@ -206,7 +240,13 @@ const PricingCardView = ({ | |||||
| - | - | ||||
| </Tag> | </Tag> | ||||
| ); | ); | ||||
| if (hasMatrixPricing(record)) { | |||||
| if (hasDisplayPricing(record)) { | |||||
| billingTag = ( | |||||
| <Tag key='billing' shape='circle' color='orange' size='small'> | |||||
| {t('展示价格')} | |||||
| </Tag> | |||||
| ); | |||||
| } else if (hasMatrixPricing(record)) { | |||||
| billingTag = ( | billingTag = ( | ||||
| <Tag key='billing' shape='circle' color='purple' size='small'> | <Tag key='billing' shape='circle' color='purple' size='small'> | ||||
| {t('矩阵计费')} | {t('矩阵计费')} | ||||
| @@ -24,6 +24,7 @@ import { | |||||
| renderModelTag, | renderModelTag, | ||||
| stringToColor, | stringToColor, | ||||
| calculateModelPrice, | calculateModelPrice, | ||||
| hasDisplayPricing, | |||||
| hasMatrixPricing, | hasMatrixPricing, | ||||
| getLobeHubIcon, | getLobeHubIcon, | ||||
| } from '../../../../../helpers'; | } from '../../../../../helpers'; | ||||
| @@ -53,6 +54,13 @@ function renderQuotaType(type, t) { | |||||
| } | } | ||||
| function renderBillingType(record, t) { | function renderBillingType(record, t) { | ||||
| if (hasDisplayPricing(record)) { | |||||
| return ( | |||||
| <Tag color='orange' shape='circle'> | |||||
| {t('展示价格')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (hasMatrixPricing(record)) { | if (hasMatrixPricing(record)) { | ||||
| return ( | return ( | ||||
| <Tag color='purple' shape='circle'> | <Tag color='purple' shape='circle'> | ||||
| @@ -137,6 +145,7 @@ export const getPricingTableColumns = ({ | |||||
| tokenUnit, | tokenUnit, | ||||
| displayPrice, | displayPrice, | ||||
| currency, | currency, | ||||
| t, | |||||
| }); | }); | ||||
| priceDataCache.set(record, cache); | priceDataCache.set(record, cache); | ||||
| } | } | ||||
| @@ -244,6 +253,23 @@ export const getPricingTableColumns = ({ | |||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| const priceData = getPriceData(record); | const priceData = getPriceData(record); | ||||
| if (priceData.isDisplayPricing) { | |||||
| return ( | |||||
| <div className='space-y-1'> | |||||
| {String(priceData.price || '') | |||||
| .split('\n') | |||||
| .map((line) => ( | |||||
| <div key={line} className='text-gray-700 font-semibold'> | |||||
| {line} | |||||
| </div> | |||||
| ))} | |||||
| <div className='text-xs text-gray-500'> | |||||
| {t('展示价格,仅用于估算')} | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| if (priceData.isMatrix) { | if (priceData.isMatrix) { | ||||
| return ( | return ( | ||||
| <div className='space-y-1'> | <div className='space-y-1'> | ||||
| @@ -6,6 +6,7 @@ const sectionStyle = { | |||||
| borderRadius: 6, | borderRadius: 6, | ||||
| background: 'var(--semi-color-bg-1)', | background: 'var(--semi-color-bg-1)', | ||||
| overflow: 'hidden', | overflow: 'hidden', | ||||
| minWidth: 0, | |||||
| }; | }; | ||||
| const headerStyle = { | const headerStyle = { | ||||
| @@ -20,7 +21,7 @@ const bodyStyle = { | |||||
| const gridStyle = { | const gridStyle = { | ||||
| display: 'grid', | display: 'grid', | ||||
| gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | |||||
| gridTemplateColumns: 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))', | |||||
| gap: '10px 16px', | gap: '10px 16px', | ||||
| }; | }; | ||||
| @@ -28,18 +29,45 @@ const itemStyle = { | |||||
| minWidth: 0, | minWidth: 0, | ||||
| }; | }; | ||||
| const fullWidthItemStyle = { | |||||
| ...itemStyle, | |||||
| gridColumn: '1 / -1', | |||||
| }; | |||||
| const valueStyle = { | const valueStyle = { | ||||
| marginTop: 3, | marginTop: 3, | ||||
| lineHeight: 1.55, | lineHeight: 1.55, | ||||
| wordBreak: 'break-word', | wordBreak: 'break-word', | ||||
| }; | }; | ||||
| const fullWidthValueStyle = { | |||||
| ...valueStyle, | |||||
| width: '100%', | |||||
| maxWidth: '100%', | |||||
| }; | |||||
| const expandedContainerStyle = { | |||||
| padding: '12px 16px', | |||||
| width: '100%', | |||||
| boxSizing: 'border-box', | |||||
| }; | |||||
| const sectionsGridStyle = { | |||||
| display: 'grid', | |||||
| gridTemplateColumns: 'minmax(0, 1fr)', | |||||
| gap: 12, | |||||
| alignItems: 'start', | |||||
| width: '100%', | |||||
| }; | |||||
| const DetailItem = ({ item }) => ( | const DetailItem = ({ item }) => ( | ||||
| <div style={itemStyle}> | |||||
| <div style={item?.fullWidth ? fullWidthItemStyle : itemStyle}> | |||||
| <Typography.Text type='tertiary' size='small'> | <Typography.Text type='tertiary' size='small'> | ||||
| {item.key} | {item.key} | ||||
| </Typography.Text> | </Typography.Text> | ||||
| <div style={valueStyle}>{item.value}</div> | |||||
| <div style={item?.fullWidth ? fullWidthValueStyle : valueStyle}> | |||||
| {item.value} | |||||
| </div> | |||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -64,8 +92,8 @@ const DetailSection = ({ section }) => { | |||||
| const UsageLogExpandedDetail = ({ detail }) => { | const UsageLogExpandedDetail = ({ detail }) => { | ||||
| if (detail?.sections?.length) { | if (detail?.sections?.length) { | ||||
| return ( | return ( | ||||
| <div style={{ padding: '12px 16px' }}> | |||||
| <div style={{ display: 'grid', gap: 12 }}> | |||||
| <div style={expandedContainerStyle}> | |||||
| <div style={sectionsGridStyle}> | |||||
| {detail.sections.map((section) => ( | {detail.sections.map((section) => ( | ||||
| <DetailSection key={section.title} section={section} /> | <DetailSection key={section.title} section={section} /> | ||||
| ))} | ))} | ||||
| @@ -0,0 +1,102 @@ | |||||
| export function isViolationFeeLog(other) { | |||||
| return ( | |||||
| other?.violation_fee === true || | |||||
| Boolean(other?.violation_fee_code) || | |||||
| Boolean(other?.violation_fee_marker) | |||||
| ); | |||||
| } | |||||
| export function shouldShowModelPrice(modelPrice) { | |||||
| return modelPrice !== undefined && modelPrice !== null && modelPrice !== -1; | |||||
| } | |||||
| export function shouldShowUserGroupRatio(userGroupRatio) { | |||||
| return ( | |||||
| Number.isFinite(Number(userGroupRatio)) && Number(userGroupRatio) !== -1 | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * 构建计费计算项(纯数据层,不包含渲染逻辑)。 | |||||
| * 返回 `{ type, params, key, fullWidth }`,由调用方负责调用对应的 renderer。 | |||||
| */ | |||||
| export function buildBillingCalculationItem({ t, record, other }) { | |||||
| if (!record || record.type !== 2 || isViolationFeeLog(other)) { | |||||
| return null; | |||||
| } | |||||
| let type; | |||||
| let params; | |||||
| if (other?.ws || other?.audio) { | |||||
| type = 'audio'; | |||||
| params = [ | |||||
| other?.text_input, | |||||
| other?.text_output, | |||||
| other?.model_ratio, | |||||
| other?.model_price, | |||||
| other?.completion_ratio, | |||||
| other?.audio_input, | |||||
| other?.audio_output, | |||||
| other?.audio_ratio, | |||||
| other?.audio_completion_ratio, | |||||
| other?.group_ratio, | |||||
| other?.user_group_ratio, | |||||
| other?.cache_tokens || 0, | |||||
| other?.cache_ratio || 1.0, | |||||
| ]; | |||||
| } else if (other?.claude) { | |||||
| type = 'claude'; | |||||
| params = [ | |||||
| record.prompt_tokens, | |||||
| record.completion_tokens, | |||||
| other.model_ratio, | |||||
| other.model_price, | |||||
| other.completion_ratio, | |||||
| other.group_ratio, | |||||
| other?.user_group_ratio, | |||||
| other.cache_tokens || 0, | |||||
| other.cache_ratio || 1.0, | |||||
| other.cache_creation_tokens || 0, | |||||
| other.cache_creation_ratio || 1.0, | |||||
| other.cache_creation_tokens_5m || 0, | |||||
| other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, | |||||
| other.cache_creation_tokens_1h || 0, | |||||
| other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, | |||||
| ]; | |||||
| } else { | |||||
| type = 'standard'; | |||||
| params = [ | |||||
| record.prompt_tokens, | |||||
| record.completion_tokens, | |||||
| other?.model_ratio, | |||||
| other?.model_price, | |||||
| other?.completion_ratio, | |||||
| other?.group_ratio, | |||||
| other?.user_group_ratio, | |||||
| other?.cache_tokens || 0, | |||||
| other?.cache_ratio || 1.0, | |||||
| other?.image || false, | |||||
| other?.image_ratio || 0, | |||||
| other?.image_output || 0, | |||||
| other?.web_search || false, | |||||
| other?.web_search_call_count || 0, | |||||
| other?.web_search_price || 0, | |||||
| other?.file_search || false, | |||||
| other?.file_search_call_count || 0, | |||||
| other?.file_search_price || 0, | |||||
| other?.audio_input_seperate_price || false, | |||||
| other?.audio_input_token_count || 0, | |||||
| other?.audio_input_price || 0, | |||||
| other?.image_generation_call || false, | |||||
| other?.image_generation_call_price || 0, | |||||
| ]; | |||||
| } | |||||
| return { | |||||
| key: t('计费过程'), | |||||
| type, | |||||
| params, | |||||
| fullWidth: true, | |||||
| }; | |||||
| } | |||||
| @@ -0,0 +1,69 @@ | |||||
| import { describe, expect, it } from 'vitest'; | |||||
| import { | |||||
| buildBillingCalculationItem, | |||||
| shouldShowModelPrice, | |||||
| shouldShowUserGroupRatio, | |||||
| } from './UsageLogExpandedDetailData.js'; | |||||
| const t = (key) => key; | |||||
| describe('buildBillingCalculationItem', () => { | |||||
| it('returns structured data for LLM usage logs', () => { | |||||
| const item = buildBillingCalculationItem({ | |||||
| t, | |||||
| record: { | |||||
| type: 2, | |||||
| prompt_tokens: 1000, | |||||
| completion_tokens: 500, | |||||
| quota: 10, | |||||
| }, | |||||
| other: { | |||||
| model_ratio: 2, | |||||
| model_price: 0.01, | |||||
| completion_ratio: 3, | |||||
| group_ratio: 1.5, | |||||
| user_group_ratio: 0.8, | |||||
| }, | |||||
| }); | |||||
| expect(item).toMatchObject({ | |||||
| key: '计费过程', | |||||
| type: 'standard', | |||||
| fullWidth: true, | |||||
| }); | |||||
| expect(item.params).toEqual([ | |||||
| 1000, 500, 2, 0.01, 3, 1.5, 0.8, | |||||
| 0, 1, | |||||
| false, 0, 0, | |||||
| false, 0, 0, | |||||
| false, 0, 0, | |||||
| false, 0, 0, | |||||
| false, 0, | |||||
| ]); | |||||
| }); | |||||
| it('does not return billing calculation for violation fee logs', () => { | |||||
| const item = buildBillingCalculationItem({ | |||||
| t, | |||||
| record: { | |||||
| type: 2, | |||||
| prompt_tokens: 1000, | |||||
| completion_tokens: 500, | |||||
| }, | |||||
| other: { | |||||
| violation_fee: true, | |||||
| model_ratio: 2, | |||||
| model_price: 0.01, | |||||
| }, | |||||
| }); | |||||
| expect(item).toBeNull(); | |||||
| }); | |||||
| it('hides sentinel model price and user group ratio values', () => { | |||||
| expect(shouldShowModelPrice(-1)).toBe(false); | |||||
| expect(shouldShowModelPrice(0.01)).toBe(true); | |||||
| expect(shouldShowUserGroupRatio(-1)).toBe(false); | |||||
| expect(shouldShowUserGroupRatio(0.8)).toBe(true); | |||||
| }); | |||||
| }); | |||||
| @@ -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} />; | ||||
| @@ -609,6 +609,110 @@ export const selectFilter = (input, option) => { | |||||
| // ------------------------------- | // ------------------------------- | ||||
| // 模型定价计算工具函数 | // 模型定价计算工具函数 | ||||
| export const hasDisplayPricing = (record) => | |||||
| Array.isArray(record?.display_pricing) && record.display_pricing.length > 0; | |||||
| export const normalizeDisplayPricingItems = (record) => { | |||||
| if (!hasDisplayPricing(record)) return []; | |||||
| return [...record.display_pricing] | |||||
| .map((item) => { | |||||
| const discountRate = | |||||
| Number(item.discount_rate) > 0 && Number(item.discount_rate) <= 1 | |||||
| ? Number(item.discount_rate) | |||||
| : 1; | |||||
| const price = Number(item.price || 0); | |||||
| return { | |||||
| ...item, | |||||
| price, | |||||
| discount_rate: discountRate, | |||||
| effectivePrice: price * discountRate, | |||||
| unit: item.unit || 'call', | |||||
| sort_order: Number(item.sort_order || 0), | |||||
| }; | |||||
| }) | |||||
| .sort((a, b) => { | |||||
| if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order; | |||||
| return String(a.specification || '').localeCompare( | |||||
| String(b.specification || ''), | |||||
| ); | |||||
| }); | |||||
| }; | |||||
| export const formatDisplayPricingItem = ({ | |||||
| item, | |||||
| displayPrice, | |||||
| t = (key) => key, | |||||
| }) => { | |||||
| const unit = item.unit || 'call'; | |||||
| const unitText = formatDisplayPricingUnit(unit, t); | |||||
| const effectivePrice = Number(item.effectivePrice ?? item.price ?? 0); | |||||
| const hasDiscount = item.discount_rate > 0 && item.discount_rate < 1; | |||||
| return { | |||||
| ...item, | |||||
| formattedPrice: `${displayPrice(effectivePrice)} / ${unitText}`, | |||||
| originalPrice: hasDiscount | |||||
| ? `${displayPrice(Number(item.price || 0))} / ${unitText}` | |||||
| : '', | |||||
| discountText: hasDiscount | |||||
| ? `${Math.round(item.discount_rate * 100) / 10}折` | |||||
| : '-', | |||||
| }; | |||||
| }; | |||||
| const DISPLAY_PRICING_UNIT_FALLBACKS = { | |||||
| second: '秒', | |||||
| call: '次', | |||||
| image: '张图片', | |||||
| video: '个视频', | |||||
| }; | |||||
| export const formatDisplayPricingUnit = (unit, t = (key) => key) => { | |||||
| const normalizedUnit = unit || 'call'; | |||||
| const translated = t(normalizedUnit); | |||||
| if (translated && translated !== normalizedUnit) { | |||||
| return translated; | |||||
| } | |||||
| return DISPLAY_PRICING_UNIT_FALLBACKS[normalizedUnit] || normalizedUnit; | |||||
| }; | |||||
| export const summarizeDisplayPricing = ({ | |||||
| record, | |||||
| displayPrice, | |||||
| t = (key) => key, | |||||
| }) => { | |||||
| const items = normalizeDisplayPricingItems(record); | |||||
| if (items.length === 0) return null; | |||||
| const groups = new Map(); | |||||
| items.forEach((item) => { | |||||
| const unit = item.unit || 'call'; | |||||
| const list = groups.get(unit) || []; | |||||
| list.push(item); | |||||
| groups.set(unit, list); | |||||
| }); | |||||
| const lines = Array.from(groups.entries()).map(([unit, list]) => { | |||||
| const prices = list.map((item) => Number(item.effectivePrice || 0)); | |||||
| const min = Math.min(...prices); | |||||
| const max = Math.max(...prices); | |||||
| const priceText = | |||||
| min === max | |||||
| ? displayPrice(min) | |||||
| : `${displayPrice(min)} - ${displayPrice(max)}`; | |||||
| return `${priceText} / ${formatDisplayPricingUnit(unit, t)}`; | |||||
| }); | |||||
| return { | |||||
| isDisplayPricing: true, | |||||
| price: lines.join('\n'), | |||||
| unit: '', | |||||
| details: items | |||||
| .slice(0, 3) | |||||
| .map((item) => formatDisplayPricingItem({ item, displayPrice, t })), | |||||
| hiddenDetailCount: Math.max(0, items.length - 3), | |||||
| }; | |||||
| }; | |||||
| export const hasMatrixPricing = (record) => | export const hasMatrixPricing = (record) => | ||||
| !!( | !!( | ||||
| record?.pricing_config && | record?.pricing_config && | ||||
| @@ -724,6 +828,7 @@ export const calculateModelPrice = ({ | |||||
| displayPrice, | displayPrice, | ||||
| currency, | currency, | ||||
| precision = 4, | precision = 4, | ||||
| t = (key) => key, | |||||
| }) => { | }) => { | ||||
| // 1. 选择实际使用的分组 | // 1. 选择实际使用的分组 | ||||
| const ratios = groupRatio || {}; | const ratios = groupRatio || {}; | ||||
| @@ -754,6 +859,19 @@ export const calculateModelPrice = ({ | |||||
| } | } | ||||
| // 2. 根据计费类型计算价格 | // 2. 根据计费类型计算价格 | ||||
| if (hasDisplayPricing(record)) { | |||||
| const summary = summarizeDisplayPricing({ | |||||
| record, | |||||
| displayPrice, | |||||
| t, | |||||
| }); | |||||
| return { | |||||
| ...summary, | |||||
| usedGroup, | |||||
| usedGroupRatio, | |||||
| }; | |||||
| } | |||||
| if (hasMatrixPricing(record)) { | if (hasMatrixPricing(record)) { | ||||
| const summary = summarizeMatrixPricing({ | const summary = summarizeMatrixPricing({ | ||||
| record, | record, | ||||
| @@ -17,8 +17,13 @@ globalThis.localStorage = { | |||||
| const { | const { | ||||
| calculateModelPrice, | calculateModelPrice, | ||||
| formatDisplayPricingItem, | |||||
| formatDisplayPricingUnit, | |||||
| formatMatrixPrice, | formatMatrixPrice, | ||||
| hasDisplayPricing, | |||||
| hasMatrixPricing, | hasMatrixPricing, | ||||
| normalizeDisplayPricingItems, | |||||
| summarizeDisplayPricing, | |||||
| summarizeMatrixPricing, | summarizeMatrixPricing, | ||||
| } = await import('./utils.jsx'); | } = await import('./utils.jsx'); | ||||
| @@ -119,4 +124,101 @@ describe('model pricing helpers', () => { | |||||
| usedGroupRatio: 1, | usedGroupRatio: 1, | ||||
| }); | }); | ||||
| }); | }); | ||||
| it('normalizes and sorts display pricing items', () => { | |||||
| const record = { | |||||
| display_pricing: [ | |||||
| { | |||||
| specification: '1080p', | |||||
| price: 0.58333, | |||||
| unit: 'second', | |||||
| sort_order: 3, | |||||
| discount_rate: 0, | |||||
| }, | |||||
| { | |||||
| specification: '768p-6s', | |||||
| price: 0.33333, | |||||
| unit: 'second', | |||||
| sort_order: 1, | |||||
| discount_rate: 0.8, | |||||
| }, | |||||
| ], | |||||
| }; | |||||
| expect(hasDisplayPricing(record)).toBe(true); | |||||
| const items = normalizeDisplayPricingItems(record); | |||||
| expect(items.map((item) => item.specification)).toEqual([ | |||||
| '768p-6s', | |||||
| '1080p', | |||||
| ]); | |||||
| expect(items[0].effectivePrice).toBeCloseTo(0.266664); | |||||
| expect(items[1].discount_rate).toBe(1); | |||||
| expect( | |||||
| formatDisplayPricingItem({ | |||||
| item: items[0], | |||||
| displayPrice, | |||||
| t: (key) => key, | |||||
| }).discountText, | |||||
| ).toBe('8折'); | |||||
| expect(formatDisplayPricingUnit('second', (key) => key)).toBe('秒'); | |||||
| }); | |||||
| it('summarizes display pricing by unit', () => { | |||||
| const summary = summarizeDisplayPricing({ | |||||
| record: { | |||||
| display_pricing: [ | |||||
| { | |||||
| specification: '768p-6s', | |||||
| price: 0.33333, | |||||
| unit: 'second', | |||||
| sort_order: 1, | |||||
| discount_rate: 1, | |||||
| }, | |||||
| { | |||||
| specification: '1080p', | |||||
| price: 0.58333, | |||||
| unit: 'second', | |||||
| sort_order: 2, | |||||
| discount_rate: 1, | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| displayPrice, | |||||
| t: (key) => key, | |||||
| }); | |||||
| expect(summary.isDisplayPricing).toBe(true); | |||||
| expect(summary.price).toBe('$0.333 - $0.583 / 秒'); | |||||
| expect(summary.details).toHaveLength(2); | |||||
| }); | |||||
| it('calculateModelPrice prefers display pricing over matrix pricing', () => { | |||||
| const result = calculateModelPrice({ | |||||
| record: { | |||||
| quota_type: 1, | |||||
| model_price: 99, | |||||
| pricing_config: { | |||||
| billing_unit: 'per_call', | |||||
| table: [{ duration: 5, price: 88, source: 'manual' }], | |||||
| }, | |||||
| display_pricing: [ | |||||
| { | |||||
| specification: '768p-6s', | |||||
| price: 0.33333, | |||||
| unit: 'second', | |||||
| sort_order: 1, | |||||
| discount_rate: 1, | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| selectedGroup: 'default', | |||||
| groupRatio: { default: 1 }, | |||||
| tokenUnit: 'M', | |||||
| displayPrice, | |||||
| currency: 'USD', | |||||
| }); | |||||
| expect(result.isDisplayPricing).toBe(true); | |||||
| expect(result.price).toBe('$0.333 / 秒'); | |||||
| }); | |||||
| }); | }); | ||||
| @@ -39,6 +39,17 @@ import { | |||||
| import { ITEMS_PER_PAGE } from '../../constants'; | import { ITEMS_PER_PAGE } from '../../constants'; | ||||
| import { useTableCompactMode } from '../common/useTableCompactMode'; | import { useTableCompactMode } from '../common/useTableCompactMode'; | ||||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge'; | import { mergeTaskBillingLogs } from './taskBillingLogMerge'; | ||||
| import { | |||||
| buildBillingCalculationItem, | |||||
| shouldShowModelPrice, | |||||
| shouldShowUserGroupRatio, | |||||
| } from '../../components/table/usage-logs/UsageLogExpandedDetailData'; | |||||
| const billingRenderers = { | |||||
| standard: renderModelPrice, | |||||
| audio: renderAudioModelPrice, | |||||
| claude: renderClaudeModelPrice, | |||||
| }; | |||||
| export const useLogsData = () => { | export const useLogsData = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -410,7 +421,7 @@ export const useLogsData = () => { | |||||
| addKnown('billing', t('花费'), renderQuota(log.quota, 6)); | addKnown('billing', t('花费'), renderQuota(log.quota, 6)); | ||||
| addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); | addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); | ||||
| addKnown('billing', t('Completion Tokens'), log.completion_tokens); | addKnown('billing', t('Completion Tokens'), log.completion_tokens); | ||||
| if (other?.model_price !== undefined) { | |||||
| if (shouldShowModelPrice(other?.model_price)) { | |||||
| const quotaPerUnit = parseFloat( | const quotaPerUnit = parseFloat( | ||||
| localStorage.getItem('quota_per_unit') || '500000', | localStorage.getItem('quota_per_unit') || '500000', | ||||
| ); | ); | ||||
| @@ -433,7 +444,7 @@ export const useLogsData = () => { | |||||
| if (other?.group_ratio !== undefined) { | if (other?.group_ratio !== undefined) { | ||||
| addKnown('billing', t('分组倍率'), other.group_ratio); | addKnown('billing', t('分组倍率'), other.group_ratio); | ||||
| } | } | ||||
| if (other?.user_group_ratio !== undefined) { | |||||
| if (shouldShowUserGroupRatio(other?.user_group_ratio)) { | |||||
| addKnown('billing', t('用户分组倍率'), other.user_group_ratio); | addKnown('billing', t('用户分组倍率'), other.user_group_ratio); | ||||
| } | } | ||||
| if (other?.billing_mode) { | if (other?.billing_mode) { | ||||
| @@ -469,6 +480,21 @@ export const useLogsData = () => { | |||||
| if (other?.total_tokens !== undefined) { | if (other?.total_tokens !== undefined) { | ||||
| addKnown('billing', t('总 Tokens'), other.total_tokens); | addKnown('billing', t('总 Tokens'), other.total_tokens); | ||||
| } | } | ||||
| const billingCalculationItem = buildBillingCalculationItem({ | |||||
| t, | |||||
| record: log, | |||||
| other, | |||||
| }); | |||||
| if (billingCalculationItem) { | |||||
| addKnown( | |||||
| 'billing', | |||||
| billingCalculationItem.key, | |||||
| billingRenderers[billingCalculationItem.type]( | |||||
| ...billingCalculationItem.params, | |||||
| ), | |||||
| ); | |||||
| sections.billing[sections.billing.length - 1].fullWidth = true; | |||||
| } | |||||
| if (other?.pricing_snapshot) { | if (other?.pricing_snapshot) { | ||||
| const snapshot = other.pricing_snapshot || {}; | const snapshot = other.pricing_snapshot || {}; | ||||
| @@ -3295,5 +3295,14 @@ | |||||
| "重试启动失败": "Failed to start retry", | "重试启动失败": "Failed to start retry", | ||||
| "重试已启动": "Retry started", | "重试已启动": "Retry started", | ||||
| "验证完成": "Verification complete", | "验证完成": "Verification complete", | ||||
| "暂无数据": "No data" | |||||
| "暂无数据": "No data", | |||||
| "展示价格,仅用于估算": "Display price, estimate only", | |||||
| "展示价格仅用于估算,实际消耗以任务完成后的积分扣减为准。": "Display price is only an estimate. Actual consumption is based on the final quota deduction after the task completes.", | |||||
| "规格": "Specification", | |||||
| "供应商说明": "Supplier tip", | |||||
| "折扣": "Discount", | |||||
| "second": "second", | |||||
| "call": "call", | |||||
| "image": "image", | |||||
| "video": "video" | |||||
| } | } | ||||
| @@ -3249,5 +3249,14 @@ | |||||
| "重试启动失败": "重试启动失败", | "重试启动失败": "重试启动失败", | ||||
| "重试已启动": "重试已启动", | "重试已启动": "重试已启动", | ||||
| "验证完成": "验证完成", | "验证完成": "验证完成", | ||||
| "暂无数据": "暂无数据" | |||||
| "暂无数据": "暂无数据", | |||||
| "展示价格,仅用于估算": "展示价格,仅用于估算", | |||||
| "展示价格仅用于估算,实际消耗以任务完成后的积分扣减为准。": "展示价格仅用于估算,实际消耗以任务完成后的积分扣减为准。", | |||||
| "规格": "规格", | |||||
| "供应商说明": "供应商说明", | |||||
| "折扣": "折扣", | |||||
| "second": "秒", | |||||
| "call": "次", | |||||
| "image": "张图片", | |||||
| "video": "个视频" | |||||
| } | } | ||||
| @@ -0,0 +1,149 @@ | |||||
| import React from 'react'; | |||||
| import { | |||||
| Button, | |||||
| Input, | |||||
| InputNumber, | |||||
| Select, | |||||
| Space, | |||||
| Table, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { Plus, Trash2 } from 'lucide-react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { createDisplayPricingRow } from './displayPricingConfig'; | |||||
| const UNIT_OPTIONS = ['second', 'call', 'image', 'minute', 'token']; | |||||
| export default function DisplayPricingForm({ value, onChange }) { | |||||
| const { t } = useTranslation(); | |||||
| const rows = Array.isArray(value) ? value : []; | |||||
| const update = (index, patch) => { | |||||
| const next = [...rows]; | |||||
| next[index] = { ...next[index], ...patch }; | |||||
| onChange(next); | |||||
| }; | |||||
| const add = () => onChange([...rows, createDisplayPricingRow()]); | |||||
| const remove = (index) => onChange(rows.filter((_, i) => i !== index)); | |||||
| const columns = [ | |||||
| { | |||||
| title: t('规格'), | |||||
| dataIndex: 'specification', | |||||
| width: 150, | |||||
| render: (value, _, index) => ( | |||||
| <Input | |||||
| value={value || ''} | |||||
| placeholder='768p-6s' | |||||
| onChange={(specification) => update(index, { specification })} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('供应商提示'), | |||||
| dataIndex: 'official_supplier_tip', | |||||
| render: (value, _, index) => ( | |||||
| <Input | |||||
| value={value || ''} | |||||
| placeholder={t('用于用户端说明')} | |||||
| onChange={(official_supplier_tip) => | |||||
| update(index, { official_supplier_tip }) | |||||
| } | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('展示价格'), | |||||
| dataIndex: 'price', | |||||
| width: 150, | |||||
| render: (value, _, index) => ( | |||||
| <InputNumber | |||||
| value={Number(value || 0)} | |||||
| min={0} | |||||
| step={0.000001} | |||||
| prefix='$' | |||||
| style={{ width: 130 }} | |||||
| onChange={(price) => update(index, { price })} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('单位'), | |||||
| dataIndex: 'unit', | |||||
| width: 140, | |||||
| render: (value, _, index) => ( | |||||
| <Select | |||||
| value={value || undefined} | |||||
| placeholder='second' | |||||
| style={{ width: 120 }} | |||||
| allowCreate | |||||
| onChange={(unit) => update(index, { unit })} | |||||
| > | |||||
| {UNIT_OPTIONS.map((unit) => ( | |||||
| <Select.Option key={unit} value={unit}> | |||||
| {unit} | |||||
| </Select.Option> | |||||
| ))} | |||||
| </Select> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('排序'), | |||||
| dataIndex: 'sort_order', | |||||
| width: 110, | |||||
| render: (value, _, index) => ( | |||||
| <InputNumber | |||||
| value={Number(value || 0)} | |||||
| step={1} | |||||
| style={{ width: 90 }} | |||||
| onChange={(sort_order) => update(index, { sort_order })} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('折扣率'), | |||||
| dataIndex: 'discount_rate', | |||||
| width: 120, | |||||
| render: (value, _, index) => ( | |||||
| <InputNumber | |||||
| value={Number(value ?? 1)} | |||||
| min={0} | |||||
| step={0.01} | |||||
| style={{ width: 100 }} | |||||
| onChange={(discount_rate) => update(index, { discount_rate })} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('操作'), | |||||
| width: 80, | |||||
| render: (_, __, index) => ( | |||||
| <Button | |||||
| icon={<Trash2 size={16} />} | |||||
| type='danger' | |||||
| theme='borderless' | |||||
| onClick={() => remove(index)} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| ]; | |||||
| return ( | |||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Table | |||||
| columns={columns} | |||||
| dataSource={rows} | |||||
| rowKey={(_, index) => `${index}`} | |||||
| pagination={false} | |||||
| data-testid='display-pricing-form-table' | |||||
| /> | |||||
| <Button | |||||
| icon={<Plus size={16} />} | |||||
| onClick={add} | |||||
| data-testid='display-pricing-add-row' | |||||
| > | |||||
| {t('添加规格')} | |||||
| </Button> | |||||
| </Space> | |||||
| ); | |||||
| } | |||||
| @@ -17,6 +17,12 @@ import DimensionEditor from './DimensionEditor'; | |||||
| import GeneratorModal from './GeneratorModal'; | import GeneratorModal from './GeneratorModal'; | ||||
| import PreviewPanel from './PreviewPanel'; | import PreviewPanel from './PreviewPanel'; | ||||
| import PricingGrid from './PricingGrid'; | import PricingGrid from './PricingGrid'; | ||||
| import DisplayPricingForm from './DisplayPricingForm'; | |||||
| import { | |||||
| getEditableDisplayPricingItems, | |||||
| shouldSaveBillingConfig, | |||||
| validateDisplayPricingFormItems, | |||||
| } from './displayPricingConfig'; | |||||
| import { | import { | ||||
| BILLING_UNITS, | BILLING_UNITS, | ||||
| FALLBACKS, | FALLBACKS, | ||||
| @@ -28,6 +34,8 @@ export default function Editor({ | |||||
| visible, | visible, | ||||
| modelName, | modelName, | ||||
| config, | config, | ||||
| hasExistingBillingConfig, | |||||
| displayPricingItems, | |||||
| onClose, | onClose, | ||||
| onSaved, | onSaved, | ||||
| }) { | }) { | ||||
| @@ -35,6 +43,9 @@ export default function Editor({ | |||||
| const [name, setName] = useState(modelName || ''); | const [name, setName] = useState(modelName || ''); | ||||
| const [draft, setDraft] = useState(createDefaultConfig()); | const [draft, setDraft] = useState(createDefaultConfig()); | ||||
| const [jsonText, setJsonText] = useState(''); | const [jsonText, setJsonText] = useState(''); | ||||
| const [displayPricingItemsDraft, setDisplayPricingItemsDraft] = useState([]); | |||||
| const [displayPricingDirty, setDisplayPricingDirty] = useState(false); | |||||
| const [billingDirty, setBillingDirty] = useState(false); | |||||
| const [history, setHistory] = useState([]); | const [history, setHistory] = useState([]); | ||||
| const [future, setFuture] = useState([]); | const [future, setFuture] = useState([]); | ||||
| const [generatorVisible, setGeneratorVisible] = useState(false); | const [generatorVisible, setGeneratorVisible] = useState(false); | ||||
| @@ -46,15 +57,24 @@ export default function Editor({ | |||||
| setName(modelName || ''); | setName(modelName || ''); | ||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | setJsonText(JSON.stringify(next, null, 2)); | ||||
| setDisplayPricingItemsDraft( | |||||
| getEditableDisplayPricingItems( | |||||
| modelName ? { [modelName]: displayPricingItems } : {}, | |||||
| modelName, | |||||
| ), | |||||
| ); | |||||
| setDisplayPricingDirty(false); | |||||
| setBillingDirty(false); | |||||
| setHistory([]); | setHistory([]); | ||||
| setFuture([]); | setFuture([]); | ||||
| }, [visible, modelName, config]); | |||||
| }, [visible, modelName, config, displayPricingItems]); | |||||
| const validation = useMemo(() => validatePricingConfig(draft), [draft]); | const validation = useMemo(() => validatePricingConfig(draft), [draft]); | ||||
| const updateDraft = (next) => { | const updateDraft = (next) => { | ||||
| setHistory((items) => [...items, draft]); | setHistory((items) => [...items, draft]); | ||||
| setFuture([]); | setFuture([]); | ||||
| setBillingDirty(true); | |||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | setJsonText(JSON.stringify(next, null, 2)); | ||||
| }; | }; | ||||
| @@ -95,15 +115,41 @@ export default function Editor({ | |||||
| showError(validation.errors.join('\n')); | showError(validation.errors.join('\n')); | ||||
| return; | return; | ||||
| } | } | ||||
| const parsedDisplayPricing = validateDisplayPricingFormItems( | |||||
| displayPricingItemsDraft, | |||||
| ); | |||||
| if (!parsedDisplayPricing.ok) { | |||||
| showError(`${t('展示价格配置错误')}: ${parsedDisplayPricing.error}`); | |||||
| return; | |||||
| } | |||||
| const shouldSaveDisplayPricing = displayPricingDirty; | |||||
| const shouldSaveBilling = shouldSaveBillingConfig({ | |||||
| isNewModel: !modelName, | |||||
| hasExistingBillingConfig, | |||||
| billingDirty, | |||||
| displayPricingDirty, | |||||
| }); | |||||
| setSaving(true); | setSaving(true); | ||||
| try { | try { | ||||
| const res = await API.put( | |||||
| `/api/option/model_pricing/${encodeURIComponent(name.trim())}`, | |||||
| draft, | |||||
| ); | |||||
| if (!res.data?.success) { | |||||
| showError(res.data?.message || t('保存模型多维计费失败')); | |||||
| return; | |||||
| if (shouldSaveBilling) { | |||||
| const res = await API.put( | |||||
| `/api/option/model_pricing/${encodeURIComponent(name.trim())}`, | |||||
| draft, | |||||
| ); | |||||
| if (!res.data?.success) { | |||||
| showError(res.data?.message || t('保存模型多维计费失败')); | |||||
| return; | |||||
| } | |||||
| } | |||||
| if (shouldSaveDisplayPricing) { | |||||
| const displayRes = await API.put( | |||||
| `/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`, | |||||
| parsedDisplayPricing.items, | |||||
| ); | |||||
| if (!displayRes.data?.success) { | |||||
| showError(displayRes.data?.message || t('保存展示价格失败')); | |||||
| return; | |||||
| } | |||||
| } | } | ||||
| showSuccess(t('模型多维计费已保存')); | showSuccess(t('模型多维计费已保存')); | ||||
| await onSaved?.(); | await onSaved?.(); | ||||
| @@ -156,88 +202,115 @@ export default function Editor({ | |||||
| </Space> | </Space> | ||||
| } | } | ||||
| > | > | ||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Space wrap> | |||||
| <Input | |||||
| prefix={t('模型')} | |||||
| value={name} | |||||
| onChange={setName} | |||||
| style={{ width: 260 }} | |||||
| disabled={Boolean(modelName)} | |||||
| data-testid='model-pricing-model-name' | |||||
| /> | |||||
| <Select | |||||
| value={draft.billing_unit} | |||||
| onChange={setBillingUnit} | |||||
| style={{ width: 180 }} | |||||
| > | |||||
| <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | |||||
| <Select.Option value='per_1m_tokens'> | |||||
| {t('每百万 tokens')} | |||||
| </Select.Option> | |||||
| </Select> | |||||
| <Select | |||||
| value={draft.fallback?.strategy} | |||||
| onChange={(value) => | |||||
| updateDraft({ | |||||
| ...draft, | |||||
| fallback: { ...draft.fallback, strategy: value }, | |||||
| }) | |||||
| } | |||||
| style={{ width: 160 }} | |||||
| > | |||||
| <Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option> | |||||
| <Select.Option value={FALLBACKS.max}> | |||||
| {t('使用最高价')} | |||||
| </Select.Option> | |||||
| <Select.Option value={FALLBACKS.default}> | |||||
| {t('使用默认价')} | |||||
| </Select.Option> | |||||
| </Select> | |||||
| <Button | |||||
| icon={<Wand2 size={16} />} | |||||
| data-testid='model-pricing-open-generator' | |||||
| onClick={() => setGeneratorVisible(true)} | |||||
| > | |||||
| {t('生成价格行')} | |||||
| </Button> | |||||
| </Space> | |||||
| {!validation.ok && ( | |||||
| <Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}> | |||||
| {validation.errors.join('\n')} | |||||
| </Typography.Text> | |||||
| )} | |||||
| <Tabs type='line' style={{ width: '100%' }}> | |||||
| <TabPane tab={t('维度')} itemKey='dimensions'> | |||||
| <DimensionEditor | |||||
| dimensions={draft.dimensions || []} | |||||
| onChange={(dimensions) => updateDraft({ ...draft, dimensions })} | |||||
| /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('价格表')} itemKey='table'> | |||||
| <PricingGrid | |||||
| dimensions={draft.dimensions || []} | |||||
| rows={draft.table || []} | |||||
| billingUnit={draft.billing_unit} | |||||
| onChange={(table) => updateDraft({ ...draft, table })} | |||||
| <Tabs type='line' style={{ width: '100%' }}> | |||||
| <TabPane tab={t('真实计费')} itemKey='billing'> | |||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Space wrap> | |||||
| <Input | |||||
| prefix={t('模型')} | |||||
| value={name} | |||||
| onChange={setName} | |||||
| style={{ width: 260 }} | |||||
| disabled={Boolean(modelName)} | |||||
| data-testid='model-pricing-model-name' | |||||
| /> | |||||
| <Select | |||||
| value={draft.billing_unit} | |||||
| onChange={setBillingUnit} | |||||
| style={{ width: 180 }} | |||||
| > | |||||
| <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | |||||
| <Select.Option value='per_1m_tokens'> | |||||
| {t('每百万 tokens')} | |||||
| </Select.Option> | |||||
| </Select> | |||||
| <Select | |||||
| value={draft.fallback?.strategy} | |||||
| onChange={(value) => | |||||
| updateDraft({ | |||||
| ...draft, | |||||
| fallback: { ...draft.fallback, strategy: value }, | |||||
| }) | |||||
| } | |||||
| style={{ width: 160 }} | |||||
| > | |||||
| <Select.Option value={FALLBACKS.reject}> | |||||
| {t('拒绝')} | |||||
| </Select.Option> | |||||
| <Select.Option value={FALLBACKS.max}> | |||||
| {t('使用最高价')} | |||||
| </Select.Option> | |||||
| <Select.Option value={FALLBACKS.default}> | |||||
| {t('使用默认价')} | |||||
| </Select.Option> | |||||
| </Select> | |||||
| <Button | |||||
| icon={<Wand2 size={16} />} | |||||
| data-testid='model-pricing-open-generator' | |||||
| onClick={() => setGeneratorVisible(true)} | |||||
| > | |||||
| {t('生成价格表')} | |||||
| </Button> | |||||
| </Space> | |||||
| {!validation.ok && ( | |||||
| <Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}> | |||||
| {validation.errors.join('\n')} | |||||
| </Typography.Text> | |||||
| )} | |||||
| <Tabs type='line' style={{ width: '100%' }}> | |||||
| <TabPane tab={t('维度')} itemKey='dimensions'> | |||||
| <DimensionEditor | |||||
| dimensions={draft.dimensions || []} | |||||
| onChange={(dimensions) => | |||||
| updateDraft({ ...draft, dimensions }) | |||||
| } | |||||
| /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('价格表')} itemKey='table'> | |||||
| <PricingGrid | |||||
| dimensions={draft.dimensions || []} | |||||
| rows={draft.table || []} | |||||
| billingUnit={draft.billing_unit} | |||||
| onChange={(table) => updateDraft({ ...draft, table })} | |||||
| /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('预览')} itemKey='preview'> | |||||
| <PreviewPanel config={draft} /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('JSON')} itemKey='json'> | |||||
| <TextArea | |||||
| value={jsonText} | |||||
| onChange={setJsonText} | |||||
| autosize={{ minRows: 18, maxRows: 28 }} | |||||
| data-testid='model-pricing-json-editor' | |||||
| /> | |||||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | |||||
| {t('应用 JSON')} | |||||
| </Button> | |||||
| </TabPane> | |||||
| </Tabs> | |||||
| </Space> | |||||
| </TabPane> | |||||
| <TabPane tab={t('展示价格')} itemKey='display'> | |||||
| <Space vertical align='start' style={{ width: '100%' }}> | |||||
| <Input | |||||
| prefix={t('模型')} | |||||
| value={name} | |||||
| onChange={setName} | |||||
| style={{ width: 260 }} | |||||
| disabled={Boolean(modelName)} | |||||
| data-testid='display-pricing-model-name' | |||||
| /> | /> | ||||
| </TabPane> | |||||
| <TabPane tab={t('预览')} itemKey='preview'> | |||||
| <PreviewPanel config={draft} /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('JSON')} itemKey='json'> | |||||
| <TextArea | |||||
| value={jsonText} | |||||
| onChange={setJsonText} | |||||
| autosize={{ minRows: 18, maxRows: 28 }} | |||||
| data-testid='model-pricing-json-editor' | |||||
| <DisplayPricingForm | |||||
| value={displayPricingItemsDraft} | |||||
| onChange={(value) => { | |||||
| setDisplayPricingItemsDraft(value); | |||||
| setDisplayPricingDirty(true); | |||||
| }} | |||||
| /> | /> | ||||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | |||||
| {t('应用 JSON')} | |||||
| </Button> | |||||
| </TabPane> | |||||
| </Tabs> | |||||
| </Space> | |||||
| </Space> | |||||
| </TabPane> | |||||
| </Tabs> | |||||
| <GeneratorModal | <GeneratorModal | ||||
| visible={generatorVisible} | visible={generatorVisible} | ||||
| dimensions={draft.dimensions || []} | dimensions={draft.dimensions || []} | ||||
| @@ -0,0 +1,144 @@ | |||||
| 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 getEditableDisplayPricingItems = (rules, modelName) => { | |||||
| if (!modelName) return []; | |||||
| const items = rules?.[modelName]; | |||||
| return Array.isArray(items) ? items : createDefaultDisplayPricingItems(); | |||||
| }; | |||||
| export const shouldSaveBillingConfig = ({ | |||||
| isNewModel, | |||||
| hasExistingBillingConfig, | |||||
| billingDirty, | |||||
| }) => | |||||
| Boolean(isNewModel) || | |||||
| Boolean(hasExistingBillingConfig) || | |||||
| Boolean(billingDirty); | |||||
| export const createDisplayPricingRow = () => ({ | |||||
| specification: '', | |||||
| official_supplier_tip: '', | |||||
| price: 0, | |||||
| unit: '', | |||||
| sort_order: 0, | |||||
| discount_rate: 1, | |||||
| }); | |||||
| export const normalizeDisplayPricingFormItems = (items) => | |||||
| (Array.isArray(items) ? items : []).map((item) => ({ | |||||
| specification: String(item?.specification || '').trim(), | |||||
| official_supplier_tip: String(item?.official_supplier_tip || '').trim(), | |||||
| price: toFiniteNumber(item?.price, 0), | |||||
| unit: String(item?.unit || '').trim(), | |||||
| sort_order: toFiniteNumber(item?.sort_order, 0), | |||||
| discount_rate: toFiniteNumber(item?.discount_rate, 1), | |||||
| })); | |||||
| export const validateDisplayPricingFormItems = (items) => { | |||||
| const normalized = normalizeDisplayPricingFormItems(items).filter( | |||||
| (item) => | |||||
| item.specification || | |||||
| item.official_supplier_tip || | |||||
| item.price > 0 || | |||||
| item.unit, | |||||
| ); | |||||
| for (let index = 0; index < normalized.length; index++) { | |||||
| const item = normalized[index]; | |||||
| if (!item.specification) { | |||||
| return { | |||||
| ok: false, | |||||
| error: `row ${index + 1}: specification is required`, | |||||
| items: [], | |||||
| }; | |||||
| } | |||||
| if (!item.unit) { | |||||
| return { | |||||
| ok: false, | |||||
| error: `row ${index + 1}: unit is required`, | |||||
| items: [], | |||||
| }; | |||||
| } | |||||
| if (item.price < 0) { | |||||
| return { | |||||
| ok: false, | |||||
| error: `row ${index + 1}: price must be >= 0`, | |||||
| items: [], | |||||
| }; | |||||
| } | |||||
| if (item.discount_rate <= 0 || item.discount_rate > 1) { | |||||
| return { | |||||
| ok: false, | |||||
| error: `row ${index + 1}: discount_rate must be > 0 and <= 1`, | |||||
| items: [], | |||||
| }; | |||||
| } | |||||
| } | |||||
| return { ok: true, items: normalized }; | |||||
| }; | |||||
| 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 || ''), | |||||
| ); | |||||
| }); | |||||
| const toFiniteNumber = (value, fallback) => { | |||||
| const number = Number(value); | |||||
| return Number.isFinite(number) ? number : fallback; | |||||
| }; | |||||
| @@ -0,0 +1,177 @@ | |||||
| import { describe, expect, test } from 'bun:test'; | |||||
| import { | |||||
| createDefaultDisplayPricingItems, | |||||
| formatDisplayPricingJson, | |||||
| getEditableDisplayPricingItems, | |||||
| normalizeDisplayPricingFormItems, | |||||
| shouldSaveBillingConfig, | |||||
| validateDisplayPricingFormItems, | |||||
| 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"'); | |||||
| }); | |||||
| test('returns existing model display pricing or default editable items', () => { | |||||
| const rules = { | |||||
| 'video-model': [ | |||||
| { | |||||
| specification: '1080p', | |||||
| price: 0.5, | |||||
| unit: 'second', | |||||
| sort_order: 2, | |||||
| }, | |||||
| ], | |||||
| }; | |||||
| expect(getEditableDisplayPricingItems(rules, 'video-model')).toEqual( | |||||
| rules['video-model'], | |||||
| ); | |||||
| expect(getEditableDisplayPricingItems(rules, 'missing-model')).toEqual( | |||||
| createDefaultDisplayPricingItems(), | |||||
| ); | |||||
| expect(getEditableDisplayPricingItems(rules, '')).toEqual([]); | |||||
| }); | |||||
| test('normalizes structured display pricing form rows for saving', () => { | |||||
| const items = normalizeDisplayPricingFormItems([ | |||||
| { | |||||
| specification: ' 1080p ', | |||||
| official_supplier_tip: ' supplier ', | |||||
| price: '0.5', | |||||
| unit: ' second ', | |||||
| sort_order: '2', | |||||
| discount_rate: '0.8', | |||||
| }, | |||||
| { | |||||
| specification: '', | |||||
| price: 'not-a-number', | |||||
| }, | |||||
| ]); | |||||
| expect(items).toEqual([ | |||||
| { | |||||
| specification: '1080p', | |||||
| official_supplier_tip: 'supplier', | |||||
| price: 0.5, | |||||
| unit: 'second', | |||||
| sort_order: 2, | |||||
| discount_rate: 0.8, | |||||
| }, | |||||
| { | |||||
| specification: '', | |||||
| official_supplier_tip: '', | |||||
| price: 0, | |||||
| unit: '', | |||||
| sort_order: 0, | |||||
| discount_rate: 1, | |||||
| }, | |||||
| ]); | |||||
| }); | |||||
| test('validates display pricing form rows and ignores empty rows', () => { | |||||
| const empty = validateDisplayPricingFormItems([ | |||||
| { specification: '', price: 0, unit: '' }, | |||||
| ]); | |||||
| expect(empty.ok).toBe(true); | |||||
| expect(empty.items).toEqual([]); | |||||
| const invalid = validateDisplayPricingFormItems([ | |||||
| { specification: '768p', price: 1, unit: '' }, | |||||
| ]); | |||||
| expect(invalid.ok).toBe(false); | |||||
| expect(invalid.error).toContain('unit'); | |||||
| const valid = validateDisplayPricingFormItems([ | |||||
| { specification: '768p', price: '1', unit: 'second' }, | |||||
| ]); | |||||
| expect(valid.ok).toBe(true); | |||||
| expect(valid.items).toEqual([ | |||||
| { | |||||
| specification: '768p', | |||||
| official_supplier_tip: '', | |||||
| price: 1, | |||||
| unit: 'second', | |||||
| sort_order: 0, | |||||
| discount_rate: 1, | |||||
| }, | |||||
| ]); | |||||
| }); | |||||
| test('skips billing save when only display pricing changed for a legacy model', () => { | |||||
| expect( | |||||
| shouldSaveBillingConfig({ | |||||
| isNewModel: false, | |||||
| hasExistingBillingConfig: false, | |||||
| billingDirty: false, | |||||
| displayPricingDirty: true, | |||||
| }), | |||||
| ).toBe(false); | |||||
| }); | |||||
| test('saves billing config for existing rules, billing edits, or new model creation', () => { | |||||
| expect( | |||||
| shouldSaveBillingConfig({ | |||||
| isNewModel: false, | |||||
| hasExistingBillingConfig: true, | |||||
| billingDirty: false, | |||||
| displayPricingDirty: true, | |||||
| }), | |||||
| ).toBe(true); | |||||
| expect( | |||||
| shouldSaveBillingConfig({ | |||||
| isNewModel: false, | |||||
| hasExistingBillingConfig: false, | |||||
| billingDirty: true, | |||||
| displayPricingDirty: true, | |||||
| }), | |||||
| ).toBe(true); | |||||
| expect( | |||||
| shouldSaveBillingConfig({ | |||||
| isNewModel: true, | |||||
| hasExistingBillingConfig: false, | |||||
| billingDirty: false, | |||||
| displayPricingDirty: false, | |||||
| }), | |||||
| ).toBe(true); | |||||
| }); | |||||
| }); | |||||
| @@ -1,13 +1,24 @@ | |||||
| 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, | |||||
| 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 Editor from './Editor'; | import Editor from './Editor'; | ||||
| import { normalizeDisplayPricingItems } from './displayPricingConfig'; | |||||
| export default function ModelPricing() { | export default function ModelPricing() { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [rules, setRules] = useState({}); | const [rules, setRules] = useState({}); | ||||
| const [displayRules, setDisplayRules] = useState({}); | |||||
| const [legacyPrices, setLegacyPrices] = useState({}); | const [legacyPrices, setLegacyPrices] = useState({}); | ||||
| const [keyword, setKeyword] = useState(''); | const [keyword, setKeyword] = useState(''); | ||||
| const [editingModel, setEditingModel] = useState(null); | const [editingModel, setEditingModel] = useState(null); | ||||
| @@ -16,14 +27,25 @@ 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, displayRes, optionsRes] = await Promise.all([ | |||||
| API.get('/api/option/model_pricing'), | |||||
| API.get('/api/option/model_display_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 (displayRes.data?.success) { | |||||
| setDisplayRules(displayRes.data.data || {}); | |||||
| } else { | |||||
| showError(displayRes.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,26 +60,35 @@ 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), | |||||
| ...Object.keys(displayRules), | |||||
| ]); | |||||
| return [...names] | return [...names] | ||||
| .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | ||||
| .sort() | .sort() | ||||
| .map((name) => { | .map((name) => { | ||||
| const cfg = rules[name]; | const cfg = rules[name]; | ||||
| const displayItems = normalizeDisplayPricingItems(displayRules[name]); | |||||
| return { | return { | ||||
| model: name, | model: name, | ||||
| configured: Boolean(cfg), | configured: Boolean(cfg), | ||||
| displayConfigured: displayItems.length > 0, | |||||
| displayCount: displayItems.length, | |||||
| dimensions: cfg?.dimensions?.length || 0, | dimensions: cfg?.dimensions?.length || 0, | ||||
| rows: cfg?.table?.length || 0, | rows: cfg?.table?.length || 0, | ||||
| billingUnit: cfg?.billing_unit || '-', | billingUnit: cfg?.billing_unit || '-', | ||||
| legacyPrice: legacyPrices[name], | legacyPrice: legacyPrices[name], | ||||
| }; | }; | ||||
| }); | }); | ||||
| }, [rules, legacyPrices, keyword]); | |||||
| }, [rules, displayRules, legacyPrices, keyword]); | |||||
| 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 +106,54 @@ 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('展示价格'), | |||||
| dataIndex: 'displayCount', | |||||
| width: 120, | |||||
| render: (count, row) => ( | |||||
| <Tag color={row.displayConfigured ? 'green' : 'grey'}> | |||||
| {row.displayConfigured ? `${count} ${t('项')}` : t('未配置')} | |||||
| </Tag> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| 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> | ||||
| @@ -106,21 +167,45 @@ export default function ModelPricing() { | |||||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | <Space style={{ width: '100%', justifyContent: 'space-between' }}> | ||||
| <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | ||||
| <Space> | <Space> | ||||
| <Button icon={<RefreshCw size={16} />} onClick={refresh} loading={loading}> | |||||
| <Button | |||||
| icon={<RefreshCw size={16} />} | |||||
| onClick={refresh} | |||||
| loading={loading} | |||||
| > | |||||
| {t('刷新')} | {t('刷新')} | ||||
| </Button> | </Button> | ||||
| <Button icon={<Plus size={16} />} type='primary' data-testid='model-pricing-add-model' onClick={() => setEditingModel('')}> | |||||
| <Button | |||||
| icon={<Plus size={16} />} | |||||
| type='primary' | |||||
| data-testid='model-pricing-add-model' | |||||
| onClick={() => setEditingModel('')} | |||||
| > | |||||
| {t('添加模型')} | {t('添加模型')} | ||||
| </Button> | </Button> | ||||
| </Space> | </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' /> | |||||
| <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> | ||||
| <Editor | <Editor | ||||
| visible={editingModel !== null} | visible={editingModel !== null} | ||||
| modelName={editingModel || ''} | modelName={editingModel || ''} | ||||
| config={editingModel ? rules[editingModel] : null} | config={editingModel ? rules[editingModel] : null} | ||||
| hasExistingBillingConfig={Boolean(editingModel && rules[editingModel])} | |||||
| displayPricingItems={editingModel ? displayRules[editingModel] : []} | |||||
| onClose={() => setEditingModel(null)} | onClose={() => setEditingModel(null)} | ||||
| onSaved={refresh} | onSaved={refresh} | ||||
| /> | /> | ||||