fix(grok): 视频在 status 返回 video.url 后再按上游参数计费

创建/generations 仅绑定账号并缓存 create-time 定价快照;轮询到
video.url / download_url 等成功 URL 时一次性扣费。时长/分辨率/模型
优先取上游 status 字段,缺失则回退创建快照,再套默认 8s/480p。
Redis SetNX + request_id 去重,避免重复轮询双扣。
This commit is contained in:
IanShaw027
2026-08-07 16:29:57 +08:00
parent 25d2b03e90
commit b59a3df5b5
15 changed files with 601 additions and 22 deletions
+121 -9
View File
@@ -406,7 +406,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" {
if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
); err != nil {
@@ -416,8 +416,28 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
zap.Error(err),
)
}
// Defer billing until status polling observes video.url. Persist create-time
// model/duration/resolution so status can still price if upstream omits them.
if err := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, service.GrokVideoPendingBilling{
Model: requestModel,
BillingModel: firstNonEmptyString(result.BillingModel, requestModel),
UpstreamModel: result.UpstreamModel,
VideoResolution: result.VideoResolution,
VideoDurationSeconds: result.VideoDurationSeconds,
OriginalModel: clientRequestedModel(c, requestModel),
}); err != nil {
reqLog.Warn("grok_media.store_video_pending_billing_failed",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.ResponseID),
zap.Error(err),
)
}
}
if shouldRecordGrokMediaUsage(endpoint, requestModel, result) {
if endpoint == service.GrokMediaEndpointVideoStatus {
if billResult := prepareGrokVideoStatusBilling(requestCtx, h, reqLog, apiKey, subject, requestID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID)
}
} else if shouldRecordGrokMediaUsage(endpoint, requestModel, result) {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
}
reqLog.Debug("grok_media.request_completed",
@@ -459,22 +479,114 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
return account.GetMappedModel(routingModel)
}
// 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) == "" {
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
switch endpoint {
case service.GrokMediaEndpointVideosGenerations,
service.GrokMediaEndpointVideosEdits,
service.GrokMediaEndpointVideosExtensions:
return true
default:
return false
}
}
// shouldRecordGrokMediaUsage gates usage writes for immediate (image) generation.
// Async video create never bills here — status polling does when video.url appears.
// Status/content polls, empty model, and failed generations with zero billable
// image units never bill via this helper.
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string, result *service.OpenAIForwardResult) bool {
if result == nil {
return false
}
if result.VideoCount > 0 {
return true
if isGrokVideoCreateEndpoint(endpoint) || endpoint.IsVideoLookupRequest() {
return false
}
if !endpoint.IsGenerationRequest() || strings.TrimSpace(requestModel) == "" {
return false
}
return result.ImageCount > 0
}
// prepareGrokVideoStatusBilling claims one-shot billing and merges status-body
// duration/resolution/model with the create-time pending snapshot.
func prepareGrokVideoStatusBilling(
ctx context.Context,
h *OpenAIGatewayHandler,
reqLog *zap.Logger,
apiKey *service.APIKey,
subject middleware2.AuthSubject,
requestID string,
statusResult *service.OpenAIForwardResult,
) *service.OpenAIForwardResult {
if h == nil || h.gatewayService == nil || apiKey == nil || statusResult == nil {
return nil
}
// Prefer units already extracted on the forward result; otherwise re-check pending-only path
// is not enough without a status URL (VideoCount stays 0).
if statusResult.VideoCount <= 0 {
return nil
}
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, requestID, subject.UserID, apiKey.ID)
if err != nil {
reqLog.Warn("grok_media.video_billing_claim_failed", zap.String("request_id", requestID), zap.Error(err))
return nil
}
if !claimed {
reqLog.Debug("grok_media.video_billing_already_claimed", zap.String("request_id", requestID))
return nil
}
pending, loadErr := h.gatewayService.LoadGrokVideoPendingBilling(ctx, requestID, subject.UserID, apiKey.ID)
if loadErr != nil {
reqLog.Warn("grok_media.video_pending_billing_load_failed", zap.String("request_id", requestID), zap.Error(loadErr))
}
// Re-merge with pending so create-time model wins when status omits model fields.
// statusResult already has status-body duration/resolution when present.
merged := *statusResult
if pending != nil {
if strings.TrimSpace(merged.Model) == "" {
merged.Model = firstNonEmptyString(pending.BillingModel, pending.Model, pending.OriginalModel)
}
if strings.TrimSpace(merged.BillingModel) == "" {
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model, merged.Model)
}
if strings.TrimSpace(merged.UpstreamModel) == "" {
merged.UpstreamModel = pending.UpstreamModel
}
if strings.TrimSpace(merged.VideoResolution) == "" {
merged.VideoResolution = pending.VideoResolution
}
if merged.VideoDurationSeconds <= 0 {
merged.VideoDurationSeconds = pending.VideoDurationSeconds
}
if strings.TrimSpace(merged.ResponseID) == "" {
merged.ResponseID = requestID
}
}
if strings.TrimSpace(merged.Model) == "" {
merged.Model = "grok-imagine-video"
}
if strings.TrimSpace(merged.BillingModel) == "" {
merged.BillingModel = merged.Model
}
// Stable dedup key: same video task bills once across status polls.
if strings.TrimSpace(merged.RequestID) == "" {
merged.RequestID = "grok-video:" + strings.TrimSpace(firstNonEmptyString(merged.ResponseID, requestID))
}
merged.VideoCount = 1
merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution)
merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds)
return &merged
}
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func recordGrokMediaUsage(
c *gin.Context,
h *OpenAIGatewayHandler,
+5 -5
View File
@@ -42,13 +42,13 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
want: true,
},
{
name: "video generation records usage",
name: "video generation defers usage until status",
endpoint: service.GrokMediaEndpointVideosGenerations,
model: "grok-imagine-video-1.5",
want: true,
want: false,
},
{
name: "video status skips empty model usage",
name: "video status skips immediate helper (status path claims separately)",
endpoint: service.GrokMediaEndpointVideoStatus,
model: "",
want: false,
@@ -71,9 +71,9 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// 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.
// Immediate helper only bills image generation (async video bills on status).
result := &service.OpenAIForwardResult{ImageCount: 1, VideoCount: 0}
if tt.endpoint.IsGenerationRequest() && strings.TrimSpace(tt.model) != "" {
if tt.endpoint.IsGenerationRequest() && !isGrokVideoCreateEndpoint(tt.endpoint) && 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))