mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(grok): 按官方 status=done + video.url 计费并对齐字段
官方完成响应为 status=done、video.url、video.duration、顶层 model; 分辨率仅在创建请求中声明,status 无此字段故回退 create 快照/默认 480p。 拒绝 completed 等非官方状态与仅 URL 的宽松判定。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user