diff --git a/.gitignore b/.gitignore index 66e1f37..33fb8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,12 @@ CLAUDE.md logs/ docs/superpowers +# Runtime request/response capture files +/[0-9]*.json + +# Local dev server logs +web/vite-*.log + # E2E / Playwright artifacts test-artifacts/ web/.playwright/ diff --git a/controller/kling_aiping_native.go b/controller/kling_aiping_native.go index fcc6073..ebc62ad 100644 --- a/controller/kling_aiping_native.go +++ b/controller/kling_aiping_native.go @@ -11,6 +11,7 @@ import ( "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" @@ -41,9 +42,32 @@ func KlingAipingNativeTaskSubmit(c *gin.Context) { 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. @@ -108,16 +132,21 @@ func KlingAipingNativeProxy(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "route not found"}) return } - channel, _, err := service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ - Ctx: c, - TokenGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), - ModelName: route.BillingModel, - Retry: common.GetPointer(0), - RequiredChannelType: constant.ChannelTypeKlingAiping, - }) - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": err.Error()}) - return + 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()}) @@ -133,6 +162,25 @@ func KlingAipingNativeProxy(c *gin.Context) { 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 { @@ -358,23 +406,7 @@ func proxyBodyBytes(c *gin.Context, route klingaiping.Route) ([]byte, error) { if err != nil { return nil, err } - data, err := storage.Bytes() - if err != nil { - return nil, err - } - if route.PathPattern == "/v1/elements/delete" { - payload := map[string]any{} - if err := common.Unmarshal(data, &payload); err == nil { - if _, ok := payload["element_id"]; !ok { - if id := stringFromMap(payload, "id"); id != "" { - payload["element_id"] = id - delete(payload, "id") - data, _ = common.Marshal(payload) - } - } - } - } - return data, nil + return storage.Bytes() } func copyProxyResponse(c *gin.Context, resp *http.Response) { diff --git a/controller/kling_aiping_native_test.go b/controller/kling_aiping_native_test.go index 70147bf..9096534 100644 --- a/controller/kling_aiping_native_test.go +++ b/controller/kling_aiping_native_test.go @@ -45,13 +45,13 @@ func TestKlingAipingSubmitPreparationUsesModelNameBeforeModel(t *testing.T) { } func TestKlingAipingSubmitPreparationDoesNotLockChannel(t *testing.T) { - c := newControllerJSONContext(t, "/v1/kling/general/custom-voices", `{ + 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/kling/general/custom-voices", klingaiping.RouteKindSubmit) + 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) @@ -88,6 +88,10 @@ func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T) 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)) @@ -117,20 +121,6 @@ func TestKlingAipingTaskDataObjectUsesPublicTaskIDAndWatermarkURL(t *testing.T) require.Equal(t, "", videos[0].(map[string]any)["watermark_url"]) } -func TestProxyBodyBytesMapsElementDeleteIDToElementID(t *testing.T) { - c := newControllerJSONContext(t, "/v1/elements/delete", `{"id":"elem_1"}`) - route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/elements/delete", klingaiping.RouteKindProxy) - require.True(t, ok) - - data, err := proxyBodyBytes(c, route) - - require.NoError(t, err) - var payload map[string]any - require.NoError(t, common.Unmarshal(data, &payload)) - require.Equal(t, "elem_1", payload["element_id"]) - require.NotContains(t, payload, "id") -} - func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) { service.InitHttpClient() var gotAuth string @@ -141,9 +131,9 @@ func TestDoKlingAipingProxyRequestUsesSelectedContextKey(t *testing.T) { })) defer server.Close() - c := newControllerJSONContext(t, "/v1/elements/custom", ``) + c := newControllerJSONContext(t, "/v1/general/advanced-presets-elements", ``) common.SetContextKey(c, constant.ContextKeyChannelKey, "selected-key") - route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/elements/custom", klingaiping.RouteKindProxy) + route, ok := klingaiping.FindRoute(http.MethodGet, "/v1/general/advanced-presets-elements", klingaiping.RouteKindProxy) require.True(t, ok) channel := &model.Channel{ Key: "raw-channel-key", diff --git a/controller/model_pricing_controller.go b/controller/model_pricing_controller.go index 401a745..7c250df 100644 --- a/controller/model_pricing_controller.go +++ b/controller/model_pricing_controller.go @@ -2,6 +2,7 @@ package controller import ( "net/http" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" @@ -15,7 +16,7 @@ func GetModelPricingRules(c *gin.Context) { } func GetModelPricingRule(c *gin.Context) { - modelName := c.Param("model") + modelName := strings.TrimPrefix(c.Param("model"), "/") cfg := ratio_setting.GetPricingConfig(modelName) if cfg == nil { 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) { - modelName := c.Param("model") + modelName := strings.TrimPrefix(c.Param("model"), "/") var cfg types.PricingConfig if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { 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) { - modelName := c.Param("model") + modelName := strings.TrimPrefix(c.Param("model"), "/") if err := ratio_setting.DeletePricingConfig(modelName); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) return diff --git a/controller/model_pricing_controller_test.go b/controller/model_pricing_controller_test.go index 15c182a..33334cc 100644 --- a/controller/model_pricing_controller_test.go +++ b/controller/model_pricing_controller_test.go @@ -49,9 +49,9 @@ func setupModelPricingControllerTest(t *testing.T) *gin.Engine { r := gin.New() 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 } @@ -94,6 +94,24 @@ func TestModelPricingController_PutRejectsBadConfig(t *testing.T) { 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 { t.Helper() var payload []byte diff --git a/controller/pricing_user_test.go b/controller/pricing_user_test.go index 33e93a1..214488f 100644 --- a/controller/pricing_user_test.go +++ b/controller/pricing_user_test.go @@ -183,6 +183,44 @@ func TestGetUserPricingIncludesDisplayPricingWithoutChangingComputedPrice(t *tes 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,无个人倍率) func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { db := setupPricingTestDB(t) diff --git a/controller/relay.go b/controller/relay.go index 27e0eb7..40bc7e6 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -296,7 +296,7 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta { return meta } -func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { +func concreteTaskVideoBindingGroup(c *gin.Context, group string) string { group = strings.TrimSpace(group) if group == "" { group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) @@ -311,15 +311,24 @@ func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { 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) - if group == "" || group == "auto" { + if group == "" || group == "auto" || channel == 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) { @@ -334,7 +343,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service Type: c.GetInt("channel_type"), Name: c.GetString("channel_name"), AutoBan: &autoBanInt, - }, concreteDoubaoVideoBindingGroup(c, info.TokenGroup), nil + }, concreteTaskVideoBindingGroup(c, info.TokenGroup), nil } channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) @@ -668,7 +677,7 @@ func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) { if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { channel = lockedCh - selectedGroup = concreteDoubaoVideoBindingGroup(c, relayInfo.TokenGroup) + selectedGroup = concreteTaskVideoBindingGroup(c, relayInfo.TokenGroup) if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) break @@ -683,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 } @@ -768,9 +777,9 @@ func isKlingAipingNativePath(path string) bool { "/v1/videos/motion-control", "/v1/videos/omni-video", "/v1/videos/multi-image2video", - "/v1/videos/multi-elements", - "/v1/kling/general/advanced-custom-elements", - "/v1/kling/general/custom-voices", + "/v1/videos/video-extend", + "/v1/general/advanced-custom-elements", + "/v1/general/custom-voices", ) } diff --git a/relay/channel/task/kling/aiping/adaptor.go b/relay/channel/task/kling/aiping/adaptor.go index 69be913..fb61f40 100644 --- a/relay/channel/task/kling/aiping/adaptor.go +++ b/relay/channel/task/kling/aiping/adaptor.go @@ -216,7 +216,7 @@ func normalizeRequestBody(body map[string]any, info *relaycommon.RelayInfo) { modelName = info.UpstreamModelName } if modelName != "" { - body["model_name"] = modelName + body["model_name"] = canonicalAipingModelName(modelName) } delete(body, "model") @@ -316,6 +316,26 @@ func stringValue(v any) string { 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 != "" { diff --git a/relay/channel/task/kling/aiping/adaptor_test.go b/relay/channel/task/kling/aiping/adaptor_test.go index 34f9941..e3cca9a 100644 --- a/relay/channel/task/kling/aiping/adaptor_test.go +++ b/relay/channel/task/kling/aiping/adaptor_test.go @@ -17,29 +17,38 @@ func TestRoutesUseOfficialExternalAndAipingUpstreamPaths(t *testing.T) { route, ok := FindRoute(http.MethodPost, "/v1/videos/text2video", RouteKindSubmit) require.True(t, ok) require.Equal(t, ActionText2Video, route.Action) - require.Equal(t, "/v1/videos/kling/text2video", route.UpstreamPath) + require.Equal(t, "/api/v1/multimodal/kling/videos/text2video", route.UpstreamPath) - voice, ok := FindRoute(http.MethodPost, "/v1/kling/general/custom-voices", RouteKindSubmit) + voice, ok := FindRoute(http.MethodPost, "/v1/general/custom-voices", RouteKindSubmit) require.True(t, ok) - require.Equal(t, "/v1/kling/general/custom-voices", voice.UpstreamPath) + require.Equal(t, "/api/v1/multimodal/kling/general/custom-voices", voice.UpstreamPath) - presets, ok := FindRoute(http.MethodGet, "/v1/kling/general/presets-voices", RouteKindProxy) + presets, ok := FindRoute(http.MethodGet, "/v1/general/presets-voices", RouteKindProxy) require.True(t, ok) - require.Equal(t, "/v1/kling/general/presets-voices", presets.UpstreamPath) + require.Equal(t, "/api/v1/multimodal/kling/general/presets-voices", presets.UpstreamPath) - deleteVoices, ok := FindRoute(http.MethodPost, "/v1/kling/general/delete-voices", RouteKindProxy) + 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/kling/general/delete-elements", RouteKindProxy) + 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/api"}, + ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://aiping.cn"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{ Action: ActionText2Video, }, @@ -48,7 +57,7 @@ func TestBuildRequestURLUsesAipingKlingPath(t *testing.T) { url, err := adaptor.BuildRequestURL(info) require.NoError(t, err) - require.Equal(t, "https://aiping.cn/api/v1/videos/kling/text2video", url) + require.Equal(t, "https://aiping.cn/api/v1/multimodal/kling/videos/text2video", url) } func TestFetchTaskUsesSingleTaskPath(t *testing.T) { @@ -68,7 +77,7 @@ func TestFetchTaskUsesSingleTaskPath(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - require.Equal(t, "/v1/videos/kling/text2video/upstream-task", gotPath) + require.Equal(t, "/api/v1/multimodal/kling/videos/text2video/upstream-task", gotPath) } func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) { @@ -124,6 +133,35 @@ func TestBuildRequestBodyNormalizesOfficialCompatibleFields(t *testing.T) { }, 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) diff --git a/relay/channel/task/kling/aiping/routes.go b/relay/channel/task/kling/aiping/routes.go index 362c77b..9af2223 100644 --- a/relay/channel/task/kling/aiping/routes.go +++ b/relay/channel/task/kling/aiping/routes.go @@ -8,7 +8,7 @@ const ( ActionMotionControl = "kling-motion-control" ActionOmniVideo = "kling-omni-video" ActionMultiImage2Video = "kling-multi-image2video" - ActionMultiElements = "kling-multi-elements" + ActionVideoExtend = "kling-video-extend" ActionAdvancedElements = "kling-advanced-elements-create" ActionVoicesCreate = "kling-voices-create" ActionDeleteElements = "kling-delete-elements" @@ -23,7 +23,6 @@ const ( ) const ( - ModelKlingElements = "kling-elements" ModelKlingAdvancedElements = "kling-advanced-elements" ModelCustomVoices = "custom-voices" ) @@ -38,48 +37,48 @@ type Route struct { } var Routes = []Route{ - {http.MethodPost, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindSubmit, ""}, - {http.MethodGet, "/v1/videos/text2video/:task_id", ActionText2Video, "/v1/videos/kling/text2video/:task_id", RouteKindFetch, ""}, - {http.MethodGet, "/v1/videos/text2video", ActionText2Video, "/v1/videos/kling/text2video", RouteKindList, ""}, - {http.MethodPost, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindSubmit, ""}, - {http.MethodGet, "/v1/videos/image2video/:task_id", ActionImage2Video, "/v1/videos/kling/image2video/:task_id", RouteKindFetch, ""}, - {http.MethodGet, "/v1/videos/image2video", ActionImage2Video, "/v1/videos/kling/image2video", RouteKindList, ""}, - {http.MethodPost, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindSubmit, "kling-v2-6"}, - {http.MethodGet, "/v1/videos/motion-control/:task_id", ActionMotionControl, "/v1/videos/kling/motion-control/:task_id", RouteKindFetch, "kling-v2-6"}, - {http.MethodGet, "/v1/videos/motion-control", ActionMotionControl, "/v1/videos/kling/motion-control", RouteKindList, "kling-v2-6"}, - {http.MethodPost, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindSubmit, ""}, - {http.MethodGet, "/v1/videos/omni-video/:task_id", ActionOmniVideo, "/v1/videos/kling/omni-video/:task_id", RouteKindFetch, ""}, - {http.MethodGet, "/v1/videos/omni-video", ActionOmniVideo, "/v1/videos/kling/omni-video", RouteKindList, ""}, - {http.MethodPost, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindSubmit, ""}, - {http.MethodGet, "/v1/videos/multi-image2video/:task_id", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video/:task_id", RouteKindFetch, ""}, - {http.MethodGet, "/v1/videos/multi-image2video", ActionMultiImage2Video, "/v1/videos/kling/multi-image2video", RouteKindList, ""}, - {http.MethodPost, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindSubmit, "kling-v1-6"}, - {http.MethodGet, "/v1/videos/multi-elements/:task_id", ActionMultiElements, "/v1/videos/kling/multi-elements/:task_id", RouteKindFetch, "kling-v1-6"}, - {http.MethodGet, "/v1/videos/multi-elements", ActionMultiElements, "/v1/videos/kling/multi-elements", RouteKindList, "kling-v1-6"}, + // 视频能力:文生视频 + {http.MethodPost, "/v1/videos/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/videos/multi-elements/init-selection", "kling-multi-elements-init-selection", "/v1/videos/kling/multi-elements/init-selection", RouteKindProxy, ModelKlingElements}, - {http.MethodPost, "/v1/videos/multi-elements/add-selection", "kling-multi-elements-add-selection", "/v1/videos/kling/multi-elements/add-selection", RouteKindProxy, ModelKlingElements}, - {http.MethodPost, "/v1/videos/multi-elements/delete-selection", "kling-multi-elements-delete-selection", "/v1/videos/kling/multi-elements/delete-selection", RouteKindProxy, ModelKlingElements}, - {http.MethodPost, "/v1/videos/multi-elements/clear-selection", "kling-multi-elements-clear-selection", "/v1/videos/kling/multi-elements/clear-selection", RouteKindProxy, ModelKlingElements}, - {http.MethodPost, "/v1/videos/multi-elements/preview-selection", "kling-multi-elements-preview-selection", "/v1/videos/kling/multi-elements/preview-selection", RouteKindProxy, ModelKlingElements}, + // 主体能力:自定义主体 + {http.MethodPost, "/v1/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/elements/custom", "kling-elements-custom-create", "/v1/elements/custom", RouteKindProxy, ModelKlingElements}, - {http.MethodGet, "/v1/elements/custom", "kling-elements-custom-list", "/v1/elements/custom", RouteKindProxy, ModelKlingElements}, - {http.MethodGet, "/v1/elements/:id", "kling-elements-get", "/v1/elements/:id", RouteKindProxy, ModelKlingElements}, - {http.MethodGet, "/v1/elements/presets", "kling-elements-presets", "/v1/elements/presets", RouteKindProxy, ModelKlingElements}, - {http.MethodPost, "/v1/elements/delete", "kling-elements-delete", "/v1/elements/delete", RouteKindProxy, ModelKlingElements}, - - {http.MethodPost, "/v1/kling/general/advanced-custom-elements", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements", RouteKindSubmit, ModelKlingAdvancedElements}, - {http.MethodGet, "/v1/kling/general/advanced-custom-elements/:task_id", ActionAdvancedElements, "/v1/kling/general/advanced-custom-elements/:task_id", RouteKindFetch, ModelKlingAdvancedElements}, - {http.MethodGet, "/v1/kling/general/advanced-custom-elements", "kling-advanced-elements-list", "/v1/kling/general/advanced-custom-elements", RouteKindProxy, ModelKlingAdvancedElements}, - {http.MethodGet, "/v1/kling/general/advanced-presets-elements", "kling-advanced-elements-presets", "/v1/kling/general/advanced-presets-elements", RouteKindProxy, ModelKlingAdvancedElements}, - {http.MethodPost, "/v1/kling/general/delete-elements", ActionDeleteElements, "/v1/kling/general/delete-elements", RouteKindProxy, ModelKlingAdvancedElements}, - - {http.MethodPost, "/v1/kling/general/custom-voices", ActionVoicesCreate, "/v1/kling/general/custom-voices", RouteKindSubmit, ModelCustomVoices}, - {http.MethodGet, "/v1/kling/general/custom-voices/:task_id", ActionVoicesCreate, "/v1/kling/general/custom-voices/:task_id", RouteKindFetch, ModelCustomVoices}, - {http.MethodGet, "/v1/kling/general/custom-voices", "kling-voices-list", "/v1/kling/general/custom-voices", RouteKindProxy, ModelCustomVoices}, - {http.MethodGet, "/v1/kling/general/presets-voices", "kling-voices-presets", "/v1/kling/general/presets-voices", RouteKindProxy, ModelCustomVoices}, - {http.MethodPost, "/v1/kling/general/delete-voices", ActionDeleteVoices, "/v1/kling/general/delete-voices", RouteKindProxy, ModelCustomVoices}, + // 音色能力:自定义音色 + {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) { @@ -117,7 +116,6 @@ func Models() []string { "kling-v3", "kling-video-o1", "kling-v3-omni", - ModelKlingElements, ModelKlingAdvancedElements, ModelCustomVoices, } diff --git a/router/api-router.go b/router/api-router.go index ce023a8..67e536c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -192,9 +192,9 @@ func SetApiRouter(router *gin.Engine) { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) 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) diff --git a/router/video-router.go b/router/video-router.go index 11debfe..a717979 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -25,12 +25,6 @@ func SetVideoRouter(router *gin.Engine) { klingAipingRouter := router.Group("/v1") klingAipingRouter.Use(middleware.TokenAuth()) { - klingAipingRouter.POST("/videos/multi-elements/init-selection", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/videos/multi-elements/add-selection", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/videos/multi-elements/delete-selection", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/videos/multi-elements/clear-selection", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/videos/multi-elements/preview-selection", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/videos/text2video", controller.KlingAipingNativeTaskSubmit) klingAipingRouter.GET("/videos/text2video/:task_id", controller.KlingAipingNativeTaskFetch) klingAipingRouter.GET("/videos/text2video", controller.KlingAipingNativeTaskList) @@ -46,26 +40,20 @@ func SetVideoRouter(router *gin.Engine) { klingAipingRouter.POST("/videos/multi-image2video", controller.KlingAipingNativeTaskSubmit) klingAipingRouter.GET("/videos/multi-image2video/:task_id", controller.KlingAipingNativeTaskFetch) klingAipingRouter.GET("/videos/multi-image2video", controller.KlingAipingNativeTaskList) - klingAipingRouter.POST("/videos/multi-elements", controller.KlingAipingNativeTaskSubmit) - klingAipingRouter.GET("/videos/multi-elements/:task_id", controller.KlingAipingNativeTaskFetch) - klingAipingRouter.GET("/videos/multi-elements", controller.KlingAipingNativeTaskList) - - klingAipingRouter.POST("/elements/custom", controller.KlingAipingNativeProxy) - klingAipingRouter.GET("/elements/custom", controller.KlingAipingNativeProxy) - klingAipingRouter.GET("/elements/presets", controller.KlingAipingNativeProxy) - klingAipingRouter.GET("/elements/:id", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/elements/delete", controller.KlingAipingNativeProxy) + klingAipingRouter.POST("/videos/video-extend", controller.KlingAipingNativeTaskSubmit) + klingAipingRouter.GET("/videos/video-extend/:task_id", controller.KlingAipingNativeTaskFetch) + klingAipingRouter.GET("/videos/video-extend", controller.KlingAipingNativeTaskList) - klingAipingRouter.POST("/kling/general/advanced-custom-elements", controller.KlingAipingNativeTaskSubmit) - klingAipingRouter.GET("/kling/general/advanced-custom-elements/:task_id", controller.KlingAipingNativeTaskFetch) - klingAipingRouter.GET("/kling/general/advanced-custom-elements", controller.KlingAipingNativeProxy) - klingAipingRouter.GET("/kling/general/advanced-presets-elements", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/kling/general/delete-elements", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/kling/general/custom-voices", controller.KlingAipingNativeTaskSubmit) - klingAipingRouter.GET("/kling/general/custom-voices/:task_id", controller.KlingAipingNativeTaskFetch) - klingAipingRouter.GET("/kling/general/custom-voices", controller.KlingAipingNativeProxy) - klingAipingRouter.GET("/kling/general/presets-voices", controller.KlingAipingNativeProxy) - klingAipingRouter.POST("/kling/general/delete-voices", controller.KlingAipingNativeProxy) + 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 diff --git a/router/video_router_test.go b/router/video_router_test.go index 20e8c78..26dc47d 100644 --- a/router/video_router_test.go +++ b/router/video_router_test.go @@ -17,12 +17,12 @@ func TestKlingAipingNativeRoutesDoNotFallThroughToOpenAIVideoRoutes(t *testing.T requireHandlerContains(t, r, http.MethodPost, "/v1/videos/text2video", "KlingAipingNativeTaskSubmit") requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video/:task_id", "KlingAipingNativeTaskFetch") requireHandlerContains(t, r, http.MethodGet, "/v1/videos/text2video", "KlingAipingNativeTaskList") - requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements/init-selection", "KlingAipingNativeProxy") - requireHandlerContains(t, r, http.MethodPost, "/v1/videos/multi-elements", "KlingAipingNativeTaskSubmit") - requireHandlerContains(t, r, http.MethodGet, "/v1/elements/presets", "KlingAipingNativeProxy") - requireHandlerContains(t, r, http.MethodGet, "/v1/elements/:id", "KlingAipingNativeProxy") - requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-elements", "KlingAipingNativeProxy") - requireHandlerContains(t, r, http.MethodPost, "/v1/kling/general/delete-voices", "KlingAipingNativeProxy") + requireHandlerContains(t, r, http.MethodPost, "/v1/videos/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") diff --git a/scripts/deploy-test.sh b/scripts/deploy-test.sh index 61662c7..a5f4b31 100644 --- a/scripts/deploy-test.sh +++ b/scripts/deploy-test.sh @@ -11,7 +11,7 @@ set -euo pipefail 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" OV_DIR="/root/new-api-ov" diff --git a/service/kling_asset_channel.go b/service/kling_asset_channel.go new file mode 100644 index 0000000..4836202 --- /dev/null +++ b/service/kling_asset_channel.go @@ -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) +} diff --git a/service/kling_asset_channel_test.go b/service/kling_asset_channel_test.go new file mode 100644 index 0000000..9eab53f --- /dev/null +++ b/service/kling_asset_channel_test.go @@ -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")) +} \ No newline at end of file diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 7cb48d4..eb3f004 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -3,13 +3,17 @@ package service import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" + "strings" "testing" "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" @@ -666,6 +670,76 @@ func TestCASGuardedSettle_Win(t *testing.T) { 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) { truncate(t) ctx := context.Background() @@ -713,6 +787,74 @@ func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.Task 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 // =========================================================================== diff --git a/service/task_polling.go b/service/task_polling.go index 3186905..75fba36 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -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 任务 func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { for channelId, taskIds := range taskChannelM { @@ -171,22 +207,8 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM ch, err := model.CacheGetChannel(channelId) if err != nil { 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) if adaptor == nil { @@ -227,6 +249,7 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM continue } + oldStatus := 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.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) if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { 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 { task.Progress = "100%" @@ -305,21 +328,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann } cacheGetChannel, err := model.CacheGetChannel(channelId) 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) } adaptor := GetTaskAdaptorFunc(platform) diff --git a/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx b/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx index df31ab1..b9bfdbc 100644 --- a/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx +++ b/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx @@ -6,6 +6,7 @@ const sectionStyle = { borderRadius: 6, background: 'var(--semi-color-bg-1)', overflow: 'hidden', + minWidth: 0, }; const headerStyle = { @@ -20,7 +21,7 @@ const bodyStyle = { const gridStyle = { display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', + gridTemplateColumns: 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))', gap: '10px 16px', }; @@ -28,18 +29,45 @@ const itemStyle = { minWidth: 0, }; +const fullWidthItemStyle = { + ...itemStyle, + gridColumn: '1 / -1', +}; + const valueStyle = { marginTop: 3, lineHeight: 1.55, 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 }) => ( -
+
{item.key} -
{item.value}
+
+ {item.value} +
); @@ -64,8 +92,8 @@ const DetailSection = ({ section }) => { const UsageLogExpandedDetail = ({ detail }) => { if (detail?.sections?.length) { return ( -
-
+
+
{detail.sections.map((section) => ( ))} diff --git a/web/src/components/table/usage-logs/UsageLogExpandedDetailData.js b/web/src/components/table/usage-logs/UsageLogExpandedDetailData.js new file mode 100644 index 0000000..9fbabed --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogExpandedDetailData.js @@ -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, + }; +} \ No newline at end of file diff --git a/web/src/components/table/usage-logs/UsageLogExpandedDetailData.test.js b/web/src/components/table/usage-logs/UsageLogExpandedDetailData.test.js new file mode 100644 index 0000000..a4749b1 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogExpandedDetailData.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 01d1617..429aa94 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -39,6 +39,17 @@ import { import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; 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 = () => { const { t } = useTranslation(); @@ -410,7 +421,7 @@ export const useLogsData = () => { addKnown('billing', t('花费'), renderQuota(log.quota, 6)); addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); addKnown('billing', t('Completion Tokens'), log.completion_tokens); - if (other?.model_price !== undefined) { + if (shouldShowModelPrice(other?.model_price)) { const quotaPerUnit = parseFloat( localStorage.getItem('quota_per_unit') || '500000', ); @@ -433,7 +444,7 @@ export const useLogsData = () => { if (other?.group_ratio !== undefined) { 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); } if (other?.billing_mode) { @@ -469,6 +480,21 @@ export const useLogsData = () => { if (other?.total_tokens !== undefined) { 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) { const snapshot = other.pricing_snapshot || {}; diff --git a/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx b/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx deleted file mode 100644 index f58b226..0000000 --- a/web/src/pages/Setting/Operation/ModelPricing/DisplayPricing.jsx +++ /dev/null @@ -1,277 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - Button, - Input, - Popconfirm, - SideSheet, - Space, - Table, - Tag, - TextArea, - Typography, -} from '@douyinfe/semi-ui'; -import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { API, showError, showSuccess } from '../../../../helpers'; -import { - createDefaultDisplayPricingItems, - formatDisplayPricingJson, - normalizeDisplayPricingItems, - parseDisplayPricingJson, -} from './displayPricingConfig'; - -export default function DisplayPricing() { - const { t } = useTranslation(); - const [rules, setRules] = useState({}); - const [keyword, setKeyword] = useState(''); - const [editingModel, setEditingModel] = useState(null); - const [editingItems, setEditingItems] = useState([]); - const [loading, setLoading] = useState(false); - - const refresh = async () => { - setLoading(true); - try { - const res = await API.get('/api/option/model_display_pricing'); - if (!res.data?.success) { - showError(res.data?.message || t('加载展示价格失败')); - return; - } - setRules(res.data.data || {}); - } catch (error) { - showError(error.message || t('加载展示价格失败')); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - refresh(); - }, []); - - const data = useMemo( - () => - Object.entries(rules || {}) - .filter(([model]) => - model.toLowerCase().includes(keyword.toLowerCase()), - ) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([model, items]) => { - const normalized = normalizeDisplayPricingItems(items); - return { - model, - count: normalized.length, - preview: normalized.slice(0, 3), - items: normalized, - }; - }), - [rules, keyword], - ); - - const openEditor = (model, items) => { - setEditingModel(model); - setEditingItems(items || createDefaultDisplayPricingItems()); - }; - - const remove = async (model) => { - try { - const res = await API.delete( - `/api/option/model_display_pricing/${encodeURIComponent(model)}`, - ); - if (!res.data?.success) { - showError(res.data?.message || t('删除展示价格失败')); - return; - } - showSuccess(t('展示价格已删除')); - await refresh(); - } catch (error) { - showError(error.message || t('删除展示价格失败')); - } - }; - - const columns = [ - { title: t('模型'), dataIndex: 'model' }, - { title: t('规格数量'), dataIndex: 'count', width: 110 }, - { - title: t('规格预览'), - dataIndex: 'preview', - render: (items) => ( - - {(items || []).map((item) => ( - - {item.specification}: {item.price} / {item.unit} - - ))} - - ), - }, - { - title: t('操作'), - width: 120, - render: (_, row) => ( - - - - - - - - setEditingModel(null)} - onSaved={refresh} - /> - - ); -} - -function DisplayPricingEditor({ visible, modelName, items, onClose, onSaved }) { - const { t } = useTranslation(); - const [name, setName] = useState(modelName || ''); - const [jsonText, setJsonText] = useState(formatDisplayPricingJson(items)); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (!visible) return; - setName(modelName || ''); - setJsonText(formatDisplayPricingJson(items)); - }, [visible, modelName, items]); - - const save = async () => { - if (!name.trim()) { - showError(t('模型名称不能为空')); - return; - } - const parsed = parseDisplayPricingJson(jsonText); - if (!parsed.ok) { - showError(`${t('JSON 格式错误')}: ${parsed.error}`); - return; - } - - setSaving(true); - try { - const res = await API.put( - `/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`, - parsed.items, - ); - if (!res.data?.success) { - showError(res.data?.message || t('保存展示价格失败')); - return; - } - showSuccess(t('展示价格已保存')); - await onSaved?.(); - onClose?.(); - } catch (error) { - showError(error.message || t('保存展示价格失败')); - } finally { - setSaving(false); - } - }; - - return ( - - - - - } - > - - - - {t( - '填写数组 JSON。每一项包含 specification、official_supplier_tip、price、unit、sort_order、discount_rate;price 使用 USD 基准价。', - )} - -