From 21d0905c78339beab73d183733699fa0524e78df Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 16:36:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(grok):=20=E6=8C=89=E5=AE=98=E6=96=B9=20stat?= =?UTF-8?q?us=3Ddone=20+=20video.url=20=E8=AE=A1=E8=B4=B9=E5=B9=B6?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方完成响应为 status=done、video.url、video.duration、顶层 model; 分辨率仅在创建请求中声明,status 无此字段故回退 create 快照/默认 480p。 拒绝 completed 等非官方状态与仅 URL 的宽松判定。 --- backend/internal/handler/grok_media.go | 19 +++-- backend/internal/service/grok_media.go | 80 ++++++++++--------- .../service/grok_media_video_billing_test.go | 65 ++++++++++++--- 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index caac04ca70..dc97875b90 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -491,7 +491,8 @@ func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool { } // shouldRecordGrokMediaUsage gates usage writes for immediate (image) generation. -// Async video create never bills here — status polling does when video.url appears. +// Async video create never bills here — status polling does on official +// status=done with video.url (docs.x.ai Video Generation). // 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 { @@ -507,8 +508,9 @@ func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel return result.ImageCount > 0 } -// prepareGrokVideoStatusBilling claims one-shot billing and merges status-body -// duration/resolution/model with the create-time pending snapshot. +// prepareGrokVideoStatusBilling claims one-shot billing for official done+video.url +// responses. Duration/model prefer status body; resolution uses create-time request +// (status response does not document resolution). func prepareGrokVideoStatusBilling( ctx context.Context, h *OpenAIGatewayHandler, @@ -521,8 +523,7 @@ func prepareGrokVideoStatusBilling( 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). + // Forward already set VideoCount only when status=done && video.url (official). if statusResult.VideoCount <= 0 { return nil } @@ -539,8 +540,7 @@ func prepareGrokVideoStatusBilling( 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. + // Re-merge with pending: resolution is request-only; model/duration fill gaps. merged := *statusResult if pending != nil { if strings.TrimSpace(merged.Model) == "" { @@ -552,7 +552,8 @@ func prepareGrokVideoStatusBilling( if strings.TrimSpace(merged.UpstreamModel) == "" { merged.UpstreamModel = pending.UpstreamModel } - if strings.TrimSpace(merged.VideoResolution) == "" { + // Official status omits resolution — always prefer create request. + if strings.TrimSpace(pending.VideoResolution) != "" { merged.VideoResolution = pending.VideoResolution } if merged.VideoDurationSeconds <= 0 { @@ -573,7 +574,9 @@ func prepareGrokVideoStatusBilling( merged.RequestID = "grok-video:" + strings.TrimSpace(firstNonEmptyString(merged.ResponseID, requestID)) } merged.VideoCount = 1 + // Official default resolution is 480p when the create request omitted it. merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution) + // Official default duration is 8s when neither status nor create provided it. merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds) return &merged } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 65547b8e7b..ff3d89058e 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -419,35 +419,47 @@ func (s *OpenAIGatewayService) ClaimGrokVideoBilling( return s.cache.ClaimGrokVideoBilled(ctx, key, grokVideoBilledClaimTTL(s.cfg)) } -// IsGrokVideoStatusBillable reports whether a status response body has a downloadable -// video URL (upstream success). Empty/pending/failed responses are not billable. +// Official xAI async video status success shape (docs.x.ai Video Generation): +// +// {"status":"done","model":"grok-imagine-video-1.5","video":{"url":"...","duration":8,"respect_moderation":true}} +// +// Request may include resolution ("480p"|"720p"|"1080p"); completed status does not +// document a resolution field — bill resolution from the create-time request snapshot. + +// IsGrokVideoStatusBillable matches official success: status == "done" AND non-empty video.url. +// pending / expired / failed, or done without a video URL, are not billable. func IsGrokVideoStatusBillable(statusBody []byte) bool { - return strings.TrimSpace(extractGrokVideoStatusContentURL(statusBody)) != "" + if len(statusBody) == 0 || !gjson.ValidBytes(statusBody) { + return false + } + if !isOfficialGrokVideoStatusDone(statusBody) { + return false + } + return strings.TrimSpace(gjson.GetBytes(statusBody, "video.url").String()) != "" } -// extractGrokVideoStatusContentURL returns the first usable video URL from a status body. +func isOfficialGrokVideoStatusDone(statusBody []byte) bool { + // Official enum: pending | done | expired | failed. + return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(statusBody, "status").String()), "done") +} + +// extractGrokVideoStatusContentURL returns official video.url when present. +// Kept for content/proxy helpers that still accept rewritten proxy paths after rewrite. func extractGrokVideoStatusContentURL(body []byte) string { if len(body) == 0 || !gjson.ValidBytes(body) { return "" } - for _, path := range []string{ - "video.url", - "url", - "download_url", - "video_url", - "data.video.url", - "data.url", - "data.download_url", - } { - if u := strings.TrimSpace(gjson.GetBytes(body, path).String()); u != "" { - return u - } + if u := strings.TrimSpace(gjson.GetBytes(body, "video.url").String()); u != "" { + return u } return "" } -// ExtractGrokVideoBillingFromStatusBody builds usage units from an upstream status -// payload. Prefers status fields; falls back to create-time pending snapshot. +// ExtractGrokVideoBillingFromStatusBody builds usage units from an official done status. +// Field priority (official docs): +// - duration: video.duration (seconds) +// - model: top-level model +// - resolution: not in status response → create-time pending snapshot → default 480p func ExtractGrokVideoBillingFromStatusBody(statusBody []byte, pending *GrokVideoPendingBilling, requestID string) *OpenAIForwardResult { if !IsGrokVideoStatusBillable(statusBody) { return nil @@ -459,20 +471,14 @@ func ExtractGrokVideoBillingFromStatusBody(statusBody []byte, pending *GrokVideo durationSeconds := 0 if gjson.ValidBytes(statusBody) { - model = firstNonEmpty( - strings.TrimSpace(gjson.GetBytes(statusBody, "model").String()), - strings.TrimSpace(gjson.GetBytes(statusBody, "video.model").String()), - strings.TrimSpace(gjson.GetBytes(statusBody, "data.model").String()), - ) - resolution = firstNonEmpty( - strings.TrimSpace(gjson.GetBytes(statusBody, "resolution").String()), - strings.TrimSpace(gjson.GetBytes(statusBody, "video.resolution").String()), - strings.TrimSpace(gjson.GetBytes(statusBody, "data.resolution").String()), - ) - for _, path := range []string{"duration", "video.duration", "duration_seconds", "video.duration_seconds", "data.duration"} { - if v := gjson.GetBytes(statusBody, path); v.Exists() && v.Type == gjson.Number { - durationSeconds = int(v.Int()) - break + // Official: top-level model. + model = strings.TrimSpace(gjson.GetBytes(statusBody, "model").String()) + // Official: video.duration (number of seconds). + if v := gjson.GetBytes(statusBody, "video.duration"); v.Exists() && v.Type == gjson.Number { + durationSeconds = int(v.Int()) + if durationSeconds == 0 && v.Float() > 0 { + // Sub-second values are unexpected for this API; still accept truncated int path above. + durationSeconds = int(v.Float()) } } } @@ -486,22 +492,20 @@ func ExtractGrokVideoBillingFromStatusBody(statusBody []byte, pending *GrokVideo if upstreamModel == "" { upstreamModel = pending.UpstreamModel } - if resolution == "" { - resolution = pending.VideoResolution - } + // Official status has no resolution — always take create request when available. + resolution = pending.VideoResolution if durationSeconds <= 0 { durationSeconds = pending.VideoDurationSeconds } } if model == "" { - // Still bill once URL exists; pricing resolver uses model family defaults. + // Official default video model family when status omits model. model = "grok-imagine-video" } if billingModel == "" { billingModel = model } - // Leave empty resolution / zero duration for the handler to merge create-time - // pending snapshot before applying defaults. + // Resolution is request-only per docs; empty → handler applies official default 480p. if resolution != "" { resolution = NormalizeVideoBillingResolutionOrDefault(resolution) } diff --git a/backend/internal/service/grok_media_video_billing_test.go b/backend/internal/service/grok_media_video_billing_test.go index d4ab50972e..f3a3957bbf 100644 --- a/backend/internal/service/grok_media_video_billing_test.go +++ b/backend/internal/service/grok_media_video_billing_test.go @@ -8,12 +8,25 @@ import ( func TestIsGrokVideoStatusBillable(t *testing.T) { t.Parallel() + // Official success: status=done + video.url + require.True(t, IsGrokVideoStatusBillable([]byte(`{ + "status":"done", + "model":"grok-imagine-video-1.5", + "video":{"url":"https://vidgen.x.ai/x.mp4","duration":8,"respect_moderation":true} + }`))) + + // Official non-success states require.False(t, IsGrokVideoStatusBillable(nil)) require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"pending"}`))) - require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"completed"}`))) - require.True(t, IsGrokVideoStatusBillable([]byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/x.mp4"}}`))) - require.True(t, IsGrokVideoStatusBillable([]byte(`{"url":"https://example.com/v.mp4"}`))) - require.True(t, IsGrokVideoStatusBillable([]byte(`{"download_url":"/v1/videos/task/content"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"expired"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"failed"}`))) + // done without video.url is not billable + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"done"}`))) + // URL alone (legacy/non-official shapes) is not enough + require.False(t, IsGrokVideoStatusBillable([]byte(`{"url":"https://example.com/v.mp4"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"download_url":"/v1/videos/task/content"}`))) + // "completed" is not the official enum value + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/x.mp4"}}`))) } func TestExtractGrokVideoBillingFromStatusBodyPrefersUpstreamParams(t *testing.T) { @@ -22,19 +35,22 @@ func TestExtractGrokVideoBillingFromStatusBodyPrefersUpstreamParams(t *testing.T Model: "pending-model", BillingModel: "pending-billing", UpstreamModel: "pending-upstream", - VideoResolution: VideoBillingResolution480P, + VideoResolution: VideoBillingResolution720P, VideoDurationSeconds: 8, } + // Official completed body from docs.x.ai Video Generation. body := []byte(`{ "status":"done", - "model":"status-model", - "video":{"url":"https://vidgen.x.ai/signed.mp4","duration":12,"resolution":"720p"} + "model":"grok-imagine-video-1.5", + "video":{"url":"https://vidgen.x.ai/signed.mp4","duration":12,"respect_moderation":true} }`) result := ExtractGrokVideoBillingFromStatusBody(body, pending, "req-1") require.NotNil(t, result) require.Equal(t, 1, result.VideoCount) - require.Equal(t, "status-model", result.Model) + require.Equal(t, "grok-imagine-video-1.5", result.Model) + // Resolution is not in official status response — use create-time request. require.Equal(t, VideoBillingResolution720P, result.VideoResolution) + // Duration prefers official video.duration. require.Equal(t, 12, result.VideoDurationSeconds) } @@ -47,7 +63,8 @@ func TestExtractGrokVideoBillingFromStatusBodyFallsBackToPending(t *testing.T) { VideoResolution: VideoBillingResolution1080P, VideoDurationSeconds: 10, } - body := []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/signed.mp4"}}`) + // done + video.url, but no model/duration in body. + body := []byte(`{"status":"done","video":{"url":"https://vidgen.x.ai/signed.mp4"}}`) result := ExtractGrokVideoBillingFromStatusBody(body, pending, "req-2") require.NotNil(t, result) require.Equal(t, "create-billing", result.BillingModel) @@ -56,6 +73,19 @@ func TestExtractGrokVideoBillingFromStatusBodyFallsBackToPending(t *testing.T) { require.Equal(t, 10, result.VideoDurationSeconds) } +func TestExtractGrokVideoBillingRejectsNonDoneStatus(t *testing.T) { + t.Parallel() + pending := &GrokVideoPendingBilling{Model: "m", VideoDurationSeconds: 8, VideoResolution: "720p"} + require.Nil(t, ExtractGrokVideoBillingFromStatusBody( + []byte(`{"status":"pending","video":{"url":"https://vidgen.x.ai/x.mp4","duration":8}}`), + pending, "req", + )) + require.Nil(t, ExtractGrokVideoBillingFromStatusBody( + []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/x.mp4","duration":8}}`), + pending, "req", + )) +} + func TestGrokMediaUsageFromResponseVideoCreateDoesNotBill(t *testing.T) { t.Parallel() info := GrokMediaRequestInfo{Model: "grok-imagine-video", Resolution: "720p", DurationSeconds: 10} @@ -66,21 +96,30 @@ func TestGrokMediaUsageFromResponseVideoCreateDoesNotBill(t *testing.T) { require.Equal(t, VideoBillingResolution720P, meta.VideoResolution) } -func TestGrokMediaUsageFromResponseVideoStatusBillsOnURL(t *testing.T) { +func TestGrokMediaUsageFromResponseVideoStatusBillsOnOfficialDone(t *testing.T) { t.Parallel() meta := grokMediaUsageFromResponse( GrokMediaEndpointVideoStatus, GrokMediaRequestInfo{}, - []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/a.mp4","duration":9,"resolution":"480p"}}`), + []byte(`{"status":"done","model":"grok-imagine-video-1.5","video":{"url":"https://vidgen.x.ai/a.mp4","duration":9}}`), ) require.Equal(t, 1, meta.VideoCount) require.Equal(t, 9, meta.VideoDurationSeconds) - require.Equal(t, VideoBillingResolution480P, meta.VideoResolution) + require.Equal(t, "grok-imagine-video-1.5", meta.Model) + // Official non-done must not set billable units. pendingOnly := grokMediaUsageFromResponse( GrokMediaEndpointVideoStatus, GrokMediaRequestInfo{}, - []byte(`{"status":"completed"}`), + []byte(`{"status":"pending"}`), ) require.Equal(t, 0, pendingOnly.VideoCount) + + // completed is not official done. + completed := grokMediaUsageFromResponse( + GrokMediaEndpointVideoStatus, + GrokMediaRequestInfo{}, + []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/a.mp4","duration":9}}`), + ) + require.Equal(t, 0, completed.VideoCount) }