- 避免仅编辑展示价格时写入默认真实计费规则 - 将 video-extend 纳入 KlingAiping 原生渠道约束 - 补充相关保存决策、渠道约束和计费展示测试 Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -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/ | |||
| @@ -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) { | |||
| @@ -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", | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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", | |||
| ) | |||
| } | |||
| @@ -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 != "" { | |||
| @@ -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) | |||
| @@ -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, | |||
| } | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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") | |||
| @@ -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" | |||
| @@ -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 ( | |||
| "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 | |||
| // =========================================================================== | |||
| @@ -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) | |||
| @@ -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 }) => ( | |||
| <div style={itemStyle}> | |||
| <div style={item?.fullWidth ? fullWidthItemStyle : itemStyle}> | |||
| <Typography.Text type='tertiary' size='small'> | |||
| {item.key} | |||
| </Typography.Text> | |||
| <div style={valueStyle}>{item.value}</div> | |||
| <div style={item?.fullWidth ? fullWidthValueStyle : valueStyle}> | |||
| {item.value} | |||
| </div> | |||
| </div> | |||
| ); | |||
| @@ -64,8 +92,8 @@ const DetailSection = ({ section }) => { | |||
| const UsageLogExpandedDetail = ({ detail }) => { | |||
| if (detail?.sections?.length) { | |||
| return ( | |||
| <div style={{ padding: '12px 16px' }}> | |||
| <div style={{ display: 'grid', gap: 12 }}> | |||
| <div style={expandedContainerStyle}> | |||
| <div style={sectionsGridStyle}> | |||
| {detail.sections.map((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); | |||
| }); | |||
| }); | |||
| @@ -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 || {}; | |||
| @@ -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) => ( | |||
| <Space wrap> | |||
| {(items || []).map((item) => ( | |||
| <Tag key={item.specification}> | |||
| {item.specification}: {item.price} / {item.unit} | |||
| </Tag> | |||
| ))} | |||
| </Space> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 120, | |||
| render: (_, row) => ( | |||
| <Space> | |||
| <Button | |||
| icon={<Edit size={16} />} | |||
| theme='borderless' | |||
| data-testid={`display-pricing-edit-${row.model}`} | |||
| onClick={() => openEditor(row.model, row.items)} | |||
| /> | |||
| <Popconfirm | |||
| title={t('删除后用户端不再展示该模型的展示价格,是否继续?')} | |||
| onConfirm={() => remove(row.model)} | |||
| > | |||
| <Button | |||
| icon={<Trash2 size={16} />} | |||
| type='danger' | |||
| theme='borderless' | |||
| /> | |||
| </Popconfirm> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Space vertical align='start' spacing={2}> | |||
| <Typography.Title heading={5} style={{ margin: 0 }}> | |||
| {t('展示价格')} | |||
| </Typography.Title> | |||
| <Typography.Text type='tertiary'> | |||
| {t( | |||
| '仅用于模型广场展示和用户估算,不参与真实扣费。price 按 USD 保存,用户端会按站点货币设置换算展示。', | |||
| )} | |||
| </Typography.Text> | |||
| </Space> | |||
| <Space> | |||
| <Button | |||
| icon={<RefreshCw size={16} />} | |||
| onClick={refresh} | |||
| loading={loading} | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| type='primary' | |||
| data-testid='display-pricing-add-model' | |||
| onClick={() => openEditor('', createDefaultDisplayPricingItems())} | |||
| > | |||
| {t('添加展示价格')} | |||
| </Button> | |||
| </Space> | |||
| </Space> | |||
| <Input | |||
| placeholder={t('搜索模型')} | |||
| value={keyword} | |||
| onChange={setKeyword} | |||
| style={{ width: 280 }} | |||
| data-testid='display-pricing-search' | |||
| /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={data} | |||
| rowKey='model' | |||
| pagination={{ pageSize: 10 }} | |||
| loading={loading} | |||
| data-testid='display-pricing-table' | |||
| /> | |||
| <DisplayPricingEditor | |||
| visible={editingModel !== null} | |||
| modelName={editingModel || ''} | |||
| items={editingItems} | |||
| onClose={() => setEditingModel(null)} | |||
| onSaved={refresh} | |||
| /> | |||
| </Space> | |||
| ); | |||
| } | |||
| function DisplayPricingEditor({ visible, modelName, items, onClose, onSaved }) { | |||
| const { t } = useTranslation(); | |||
| const [name, setName] = useState(modelName || ''); | |||
| const [jsonText, setJsonText] = useState(formatDisplayPricingJson(items)); | |||
| const [saving, setSaving] = useState(false); | |||
| useEffect(() => { | |||
| if (!visible) return; | |||
| setName(modelName || ''); | |||
| setJsonText(formatDisplayPricingJson(items)); | |||
| }, [visible, modelName, items]); | |||
| const save = async () => { | |||
| if (!name.trim()) { | |||
| showError(t('模型名称不能为空')); | |||
| return; | |||
| } | |||
| const parsed = parseDisplayPricingJson(jsonText); | |||
| if (!parsed.ok) { | |||
| showError(`${t('JSON 格式错误')}: ${parsed.error}`); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const res = await API.put( | |||
| `/api/option/model_display_pricing/${encodeURIComponent(name.trim())}`, | |||
| parsed.items, | |||
| ); | |||
| if (!res.data?.success) { | |||
| showError(res.data?.message || t('保存展示价格失败')); | |||
| return; | |||
| } | |||
| showSuccess(t('展示价格已保存')); | |||
| await onSaved?.(); | |||
| onClose?.(); | |||
| } catch (error) { | |||
| showError(error.message || t('保存展示价格失败')); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }; | |||
| return ( | |||
| <SideSheet | |||
| visible={visible} | |||
| title={modelName ? t('编辑展示价格') : t('添加展示价格')} | |||
| width='min(960px, calc(100vw - 48px))' | |||
| onCancel={onClose} | |||
| data-testid='display-pricing-editor' | |||
| footer={ | |||
| <Space> | |||
| <Button onClick={onClose}>{t('取消')}</Button> | |||
| <Button | |||
| type='primary' | |||
| onClick={save} | |||
| loading={saving} | |||
| data-testid='display-pricing-save' | |||
| > | |||
| {t('保存')} | |||
| </Button> | |||
| </Space> | |||
| } | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Input | |||
| prefix={t('模型')} | |||
| value={name} | |||
| onChange={setName} | |||
| style={{ width: 320 }} | |||
| disabled={Boolean(modelName)} | |||
| data-testid='display-pricing-model-name' | |||
| /> | |||
| <Typography.Text type='tertiary'> | |||
| {t( | |||
| '填写数组 JSON。每一项包含 specification、official_supplier_tip、price、unit、sort_order、discount_rate;price 使用 USD 基准价。', | |||
| )} | |||
| </Typography.Text> | |||
| <TextArea | |||
| value={jsonText} | |||
| onChange={setJsonText} | |||
| autosize={{ minRows: 18, maxRows: 30 }} | |||
| data-testid='display-pricing-json-editor' | |||
| /> | |||
| </Space> | |||
| </SideSheet> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,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 PreviewPanel from './PreviewPanel'; | |||
| import PricingGrid from './PricingGrid'; | |||
| import DisplayPricingForm from './DisplayPricingForm'; | |||
| import { | |||
| getEditableDisplayPricingItems, | |||
| shouldSaveBillingConfig, | |||
| validateDisplayPricingFormItems, | |||
| } from './displayPricingConfig'; | |||
| import { | |||
| BILLING_UNITS, | |||
| FALLBACKS, | |||
| @@ -28,6 +34,8 @@ export default function Editor({ | |||
| visible, | |||
| modelName, | |||
| config, | |||
| hasExistingBillingConfig, | |||
| displayPricingItems, | |||
| onClose, | |||
| onSaved, | |||
| }) { | |||
| @@ -35,6 +43,9 @@ export default function Editor({ | |||
| const [name, setName] = useState(modelName || ''); | |||
| const [draft, setDraft] = useState(createDefaultConfig()); | |||
| const [jsonText, setJsonText] = useState(''); | |||
| const [displayPricingItemsDraft, setDisplayPricingItemsDraft] = useState([]); | |||
| const [displayPricingDirty, setDisplayPricingDirty] = useState(false); | |||
| const [billingDirty, setBillingDirty] = useState(false); | |||
| const [history, setHistory] = useState([]); | |||
| const [future, setFuture] = useState([]); | |||
| const [generatorVisible, setGeneratorVisible] = useState(false); | |||
| @@ -46,15 +57,24 @@ export default function Editor({ | |||
| setName(modelName || ''); | |||
| setDraft(next); | |||
| setJsonText(JSON.stringify(next, null, 2)); | |||
| setDisplayPricingItemsDraft( | |||
| getEditableDisplayPricingItems( | |||
| modelName ? { [modelName]: displayPricingItems } : {}, | |||
| modelName, | |||
| ), | |||
| ); | |||
| setDisplayPricingDirty(false); | |||
| setBillingDirty(false); | |||
| setHistory([]); | |||
| setFuture([]); | |||
| }, [visible, modelName, config]); | |||
| }, [visible, modelName, config, displayPricingItems]); | |||
| const validation = useMemo(() => validatePricingConfig(draft), [draft]); | |||
| const updateDraft = (next) => { | |||
| setHistory((items) => [...items, draft]); | |||
| setFuture([]); | |||
| setBillingDirty(true); | |||
| setDraft(next); | |||
| setJsonText(JSON.stringify(next, null, 2)); | |||
| }; | |||
| @@ -95,15 +115,41 @@ export default function Editor({ | |||
| showError(validation.errors.join('\n')); | |||
| 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); | |||
| 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('模型多维计费已保存')); | |||
| await onSaved?.(); | |||
| @@ -156,88 +202,115 @@ export default function Editor({ | |||
| </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 | |||
| visible={generatorVisible} | |||
| dimensions={draft.dimensions || []} | |||
| @@ -28,6 +28,84 @@ export const createDefaultDisplayPricingItems = () => [ | |||
| 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 { | |||
| @@ -59,3 +137,8 @@ export const normalizeDisplayPricingItems = (items) => | |||
| String(right?.specification || ''), | |||
| ); | |||
| }); | |||
| const toFiniteNumber = (value, fallback) => { | |||
| const number = Number(value); | |||
| return Number.isFinite(number) ? number : fallback; | |||
| }; | |||
| @@ -2,6 +2,10 @@ import { describe, expect, test } from 'bun:test'; | |||
| import { | |||
| createDefaultDisplayPricingItems, | |||
| formatDisplayPricingJson, | |||
| getEditableDisplayPricingItems, | |||
| normalizeDisplayPricingFormItems, | |||
| shouldSaveBillingConfig, | |||
| validateDisplayPricingFormItems, | |||
| parseDisplayPricingJson, | |||
| } from './displayPricingConfig'; | |||
| @@ -46,4 +50,128 @@ describe('displayPricingConfig utilities', () => { | |||
| 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); | |||
| }); | |||
| }); | |||
| @@ -6,20 +6,19 @@ import { | |||
| Popconfirm, | |||
| Space, | |||
| Table, | |||
| TabPane, | |||
| Tabs, | |||
| Tag, | |||
| Typography, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { API, showError, showSuccess } from '../../../../helpers'; | |||
| import DisplayPricing from './DisplayPricing'; | |||
| import Editor from './Editor'; | |||
| import { normalizeDisplayPricingItems } from './displayPricingConfig'; | |||
| export default function ModelPricing() { | |||
| const { t } = useTranslation(); | |||
| const [rules, setRules] = useState({}); | |||
| const [displayRules, setDisplayRules] = useState({}); | |||
| const [legacyPrices, setLegacyPrices] = useState({}); | |||
| const [keyword, setKeyword] = useState(''); | |||
| const [editingModel, setEditingModel] = useState(null); | |||
| @@ -28,8 +27,9 @@ export default function ModelPricing() { | |||
| const refresh = async () => { | |||
| setLoading(true); | |||
| try { | |||
| const [rulesRes, optionsRes] = await Promise.all([ | |||
| 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) { | |||
| @@ -37,6 +37,11 @@ export default function ModelPricing() { | |||
| } else { | |||
| showError(rulesRes.data?.message || t('加载模型多维计费失败')); | |||
| } | |||
| if (displayRes.data?.success) { | |||
| setDisplayRules(displayRes.data.data || {}); | |||
| } else { | |||
| showError(displayRes.data?.message || t('加载展示价格失败')); | |||
| } | |||
| if (optionsRes.data?.success) { | |||
| const modelPrice = | |||
| optionsRes.data.data?.find((item) => item.key === 'ModelPrice') | |||
| @@ -58,22 +63,26 @@ export default function ModelPricing() { | |||
| const names = new Set([ | |||
| ...Object.keys(legacyPrices), | |||
| ...Object.keys(rules), | |||
| ...Object.keys(displayRules), | |||
| ]); | |||
| return [...names] | |||
| .filter((name) => name.toLowerCase().includes(keyword.toLowerCase())) | |||
| .sort() | |||
| .map((name) => { | |||
| const cfg = rules[name]; | |||
| const displayItems = normalizeDisplayPricingItems(displayRules[name]); | |||
| return { | |||
| model: name, | |||
| configured: Boolean(cfg), | |||
| displayConfigured: displayItems.length > 0, | |||
| displayCount: displayItems.length, | |||
| dimensions: cfg?.dimensions?.length || 0, | |||
| rows: cfg?.table?.length || 0, | |||
| billingUnit: cfg?.billing_unit || '-', | |||
| legacyPrice: legacyPrices[name], | |||
| }; | |||
| }); | |||
| }, [rules, legacyPrices, keyword]); | |||
| }, [rules, displayRules, legacyPrices, keyword]); | |||
| const remove = async (model) => { | |||
| try { | |||
| @@ -106,6 +115,16 @@ export default function ModelPricing() { | |||
| { title: t('维度'), dataIndex: 'dimensions', width: 90 }, | |||
| { title: t('行数'), dataIndex: 'rows', width: 90 }, | |||
| { title: t('计费单位'), dataIndex: 'billingUnit', width: 150 }, | |||
| { | |||
| 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', | |||
| @@ -144,56 +163,49 @@ export default function ModelPricing() { | |||
| return ( | |||
| <Card style={{ marginTop: 10 }} data-testid='model-pricing-page'> | |||
| <Tabs type='line'> | |||
| <TabPane tab={t('真实计费')} itemKey='billing'> | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}> | |||
| {t('模型多维计费')} | |||
| </Typography.Title> | |||
| <Space> | |||
| <Button | |||
| icon={<RefreshCw size={16} />} | |||
| onClick={refresh} | |||
| loading={loading} | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| type='primary' | |||
| data-testid='model-pricing-add-model' | |||
| onClick={() => setEditingModel('')} | |||
| > | |||
| {t('添加模型')} | |||
| </Button> | |||
| </Space> | |||
| </Space> | |||
| <Input | |||
| placeholder={t('搜索模型')} | |||
| value={keyword} | |||
| onChange={setKeyword} | |||
| style={{ width: 280 }} | |||
| data-testid='model-pricing-search' | |||
| /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={data} | |||
| rowKey='model' | |||
| pagination={{ pageSize: 10 }} | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | |||
| <Space> | |||
| <Button | |||
| icon={<RefreshCw size={16} />} | |||
| onClick={refresh} | |||
| loading={loading} | |||
| data-testid='model-pricing-table' | |||
| /> | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| type='primary' | |||
| data-testid='model-pricing-add-model' | |||
| onClick={() => setEditingModel('')} | |||
| > | |||
| {t('添加模型')} | |||
| </Button> | |||
| </Space> | |||
| </TabPane> | |||
| <TabPane tab={t('展示价格')} itemKey='display'> | |||
| <DisplayPricing /> | |||
| </TabPane> | |||
| </Tabs> | |||
| </Space> | |||
| <Input | |||
| placeholder={t('搜索模型')} | |||
| value={keyword} | |||
| onChange={setKeyword} | |||
| style={{ width: 280 }} | |||
| data-testid='model-pricing-search' | |||
| /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={data} | |||
| rowKey='model' | |||
| pagination={{ pageSize: 10 }} | |||
| loading={loading} | |||
| data-testid='model-pricing-table' | |||
| /> | |||
| </Space> | |||
| <Editor | |||
| visible={editingModel !== null} | |||
| modelName={editingModel || ''} | |||
| config={editingModel ? rules[editingModel] : null} | |||
| hasExistingBillingConfig={Boolean(editingModel && rules[editingModel])} | |||
| displayPricingItems={editingModel ? displayRules[editingModel] : []} | |||
| onClose={() => setEditingModel(null)} | |||
| onSaved={refresh} | |||
| /> | |||