diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 289cf45354..c5edefd81c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1235,6 +1235,21 @@ type VideoPriceConfig struct { Price1080P *float64 // 1080p 视频价格(nil 表示使用默认值) } +const ( + defaultImageGenerationPrice = 0.134 + + defaultGrokImagineImagePrice1K = 0.02 + defaultGrokImagineImagePrice2K = 0.02 + defaultGrokImagineImageQualityPrice1K = 0.05 + defaultGrokImagineImageQualityPrice2K = 0.07 + + defaultGrokImagineVideoPrice480P = 0.05 + defaultGrokImagineVideoPrice720P = 0.07 + defaultGrokImagineVideo15Price480P = 0.08 + defaultGrokImagineVideo15Price720P = 0.14 + defaultGrokImagineVideo15Price1080P = 0.25 +) + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" @@ -1340,6 +1355,10 @@ func (s *BillingService) getVideoUnitPrice(model string, resolution string, grou // getDefaultImagePrice 获取 LiteLLM 默认图片价格 func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 { + if price, ok := getDefaultGrokImagineImagePrice(model, imageSize); ok { + return price + } + basePrice := 0.0 // 从 PricingService 获取 output_cost_per_image @@ -1352,7 +1371,7 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl // 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview) if basePrice <= 0 { - basePrice = 0.134 + basePrice = defaultImageGenerationPrice } // 2K 尺寸 1.5 倍,4K 尺寸翻倍 @@ -1367,9 +1386,71 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl } func (s *BillingService) getDefaultVideoPrice(model string, resolution string) float64 { - _ = resolution + if price, ok := getDefaultGrokImagineVideoPrice(model, resolution); ok { + return price + } + // The bundled LiteLLM schema does not expose an output video generation price. // Keep the historical model default as the fallback, while letting group-level // video prices override it independently from image prices. return s.getDefaultImagePrice(model, ImageBillingSize2K) } + +func getDefaultGrokImagineImagePrice(model string, imageSize string) (float64, bool) { + model = strings.ToLower(strings.TrimSpace(model)) + switch model { + case "grok-imagine-image-quality": + return getGrokImagineImageTierPrice( + imageSize, + defaultGrokImagineImageQualityPrice1K, + defaultGrokImagineImageQualityPrice2K, + ), true + case "grok-imagine", "grok-imagine-image", "grok-imagine-edit": + return getGrokImagineImageTierPrice( + imageSize, + defaultGrokImagineImagePrice1K, + defaultGrokImagineImagePrice2K, + ), true + default: + return 0, false + } +} + +func getGrokImagineImageTierPrice(imageSize string, price1K float64, price2K float64) float64 { + switch NormalizeImageBillingTierOrDefault(imageSize) { + case ImageBillingSize1K: + return price1K + case ImageBillingSize2K, ImageBillingSize4K: + return price2K + default: + return price2K + } +} + +func getDefaultGrokImagineVideoPrice(model string, resolution string) (float64, bool) { + model = strings.ToLower(strings.TrimSpace(model)) + switch { + case strings.HasPrefix(model, "grok-imagine-video-1.5"): + switch NormalizeVideoBillingResolutionOrDefault(resolution) { + case VideoBillingResolution480P: + return defaultGrokImagineVideo15Price480P, true + case VideoBillingResolution720P: + return defaultGrokImagineVideo15Price720P, true + case VideoBillingResolution1080P: + return defaultGrokImagineVideo15Price1080P, true + default: + return defaultGrokImagineVideo15Price480P, true + } + case strings.HasPrefix(model, "grok-imagine-video"): + switch NormalizeVideoBillingResolutionOrDefault(resolution) { + case VideoBillingResolution480P: + return defaultGrokImagineVideoPrice480P, true + case VideoBillingResolution720P, VideoBillingResolution1080P: + return defaultGrokImagineVideoPrice720P, true + default: + return defaultGrokImagineVideoPrice480P, true + } + default: + return 0, false + } +} diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index bafa4430a0..f4c4213533 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -886,6 +886,36 @@ func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) { require.Equal(t, string(BillingModeVideo), videoCost.BillingMode) } +func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { + svc := newTestBillingService() + + standard1K := svc.CalculateImageCost("grok-imagine-image", "1K", 1, nil, 1.0) + standard2K := svc.CalculateImageCost("grok-imagine-image", "2K", 1, nil, 1.0) + quality1K := svc.CalculateImageCost("grok-imagine-image-quality", "1K", 1, nil, 1.0) + quality2K := svc.CalculateImageCost("grok-imagine-image-quality", "2K", 1, nil, 1.0) + + require.InDelta(t, 0.02, standard1K.TotalCost, 1e-10) + require.InDelta(t, 0.02, standard2K.TotalCost, 1e-10) + require.InDelta(t, 0.05, quality1K.TotalCost, 1e-10) + require.InDelta(t, 0.07, quality2K.TotalCost, 1e-10) +} + +func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) { + svc := newTestBillingService() + + standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, nil, 1.0) + standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, nil, 1.0) + video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, nil, 1.0) + video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, nil, 1.0) + video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, nil, 1.0) + + require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10) + require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10) + require.InDelta(t, 0.08, video15_480P.TotalCost, 1e-10) + require.InDelta(t, 0.14, video15_720P.TotalCost, 1e-10) + require.InDelta(t, 0.25, video15_1080P.TotalCost, 1e-10) +} + func TestIsModelSupported(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index a59852d576..c3622a82e9 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1853,6 +1853,45 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) } +func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing.T) { + groupID := int64(1261) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "video-default-rate-card", + ResponseID: "video-default-rate-card", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 101261, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 201261}, + Account: &Account{ID: 301261, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.InDelta(t, 0.14, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.14, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { groupID := int64(127) channelPrice := 0.201 diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 389d4f9ea3..31771051b1 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -843,7 +843,7 @@ step="0.001" min="0" class="input" - placeholder="0.134" + :placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_1k')" />