fix(grok): 按官方 status=done + video.url 计费并对齐字段

官方完成响应为 status=done、video.url、video.duration、顶层 model;
分辨率仅在创建请求中声明,status 无此字段故回退 create 快照/默认 480p。
拒绝 completed 等非官方状态与仅 URL 的宽松判定。
This commit is contained in:
IanShaw027
2026-08-07 16:36:15 +08:00
parent 962da308cc
commit 21d0905c78
3 changed files with 105 additions and 59 deletions
+11 -8
View File
@@ -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
}
+42 -38
View File
@@ -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)
}
@@ -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)
}