From 451abc3aa5c81e3c65b3c10a8200ab853b6ed6c6 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 13:08:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(grok):=20=E5=8A=A0=E5=9B=BA=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E8=AE=A1=E8=B4=B9=E9=97=A8=E6=8E=A7=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=88=86=E7=BB=84=E6=8C=89=E6=A8=A1=E5=9E=8B=E4=BB=B7?= =?UTF-8?q?=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成类媒体仅在存在真实 image/video 计费单元时写 usage,避免空结果误扣费。 Create/Update 分组输入支持 video_model_prices,与仓储 JSONB 字段打通。 本阶段不引入假 voice 路由(上游音频路径依赖更广协议层)。 --- backend/internal/handler/grok_media.go | 18 +++++++++++++++--- backend/internal/handler/grok_media_test.go | 14 +++++++++++++- backend/internal/service/admin_group.go | 5 +++++ backend/internal/service/admin_service.go | 4 ++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index e11bf7f08..6df460c06 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -417,7 +417,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. ) } } - if shouldRecordGrokMediaUsage(endpoint, requestModel) { + if shouldRecordGrokMediaUsage(endpoint, requestModel, result) { recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) } reqLog.Debug("grok_media.request_completed", @@ -459,8 +459,20 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul return account.GetMappedModel(routingModel) } -func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool { - return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != "" +// shouldRecordGrokMediaUsage gates usage writes for Grok media generation only. +// Status/content polls, empty model, and failed generations with zero billable +// image/video units never bill. +func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string, result *service.OpenAIForwardResult) bool { + if !endpoint.IsGenerationRequest() || strings.TrimSpace(requestModel) == "" { + return false + } + if result == nil { + return false + } + if result.VideoCount > 0 { + return true + } + return result.ImageCount > 0 } func recordGrokMediaUsage( diff --git a/backend/internal/handler/grok_media_test.go b/backend/internal/handler/grok_media_test.go index fbd2af820..a6986d09a 100644 --- a/backend/internal/handler/grok_media_test.go +++ b/backend/internal/handler/grok_media_test.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/service" @@ -68,7 +69,18 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model)) + // Nil result must never bill. + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, nil)) + // Non-nil result with units only bills generation endpoints with model. + result := &service.OpenAIForwardResult{ImageCount: 1, VideoCount: 0} + if tt.endpoint.IsGenerationRequest() && strings.TrimSpace(tt.model) != "" { + require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result)) + } else { + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result)) + } + // Zero billable units never bill even for generation + model. + empty := &service.OpenAIForwardResult{} + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, empty)) }) } } diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 910f66843..c591c2575 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -476,6 +476,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn VideoPrice480P: videoPrice480P, VideoPrice720P: videoPrice720P, VideoPrice1080P: videoPrice1080P, + VideoModelPrices: NormalizeVideoModelPrices(input.VideoModelPrices), WebSearchPricePerCall: webSearchPricePerCall, ClaudeCodeOnly: input.ClaudeCodeOnly, FallbackGroupID: input.FallbackGroupID, @@ -755,6 +756,10 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.VideoPrice1080P != nil { group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P) } + // nil = leave unchanged; empty map = clear per-model prices. + if input.VideoModelPrices != nil { + group.VideoModelPrices = NormalizeVideoModelPrices(input.VideoModelPrices) + } if input.WebSearchPricePerCall != nil { group.WebSearchPricePerCall = normalizePrice(input.WebSearchPricePerCall) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index a620f86f1..a338956b6 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -238,6 +238,8 @@ type CreateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价。 + VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);nil/负数按默认价 0.01 处理 WebSearchPricePerCall *float64 ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 @@ -303,6 +305,8 @@ type UpdateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // VideoModelPrices 可选按模型族×分辨率覆盖;nil 表示不修改,空 map 表示清除。 + VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示不修改,负数表示清除回默认价 0.01 WebSearchPricePerCall *float64 ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端