diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 8cd16924d..359b29e5b 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -188,6 +188,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. var oauth429FailoverState service.OpenAIOAuth429FailoverState mediaEligibilityRejected := false switchCount := 0 + videoCreateStartedAt := "" + if isGrokVideoCreateEndpoint(endpoint) { + videoCreateStartedAt = service.GrokVideoPendingCreatedAtNow() + } maxAccountSwitches := h.maxAccountSwitches if maxAccountSwitches <= 0 { maxAccountSwitches = 3 @@ -425,6 +429,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. VideoResolution: result.VideoResolution, VideoDurationSeconds: result.VideoDurationSeconds, OriginalModel: clientRequestedModel(c, requestModel), + // Wall-clock start for usage duration_ms: create accepted → first done discovery. + CreatedAt: videoCreateStartedAt, }); err != nil { reqLog.Warn("grok_media.store_video_pending_billing_failed", zap.Int64("account_id", account.ID), @@ -587,6 +593,14 @@ func prepareGrokVideoCompletionBilling( merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution) // Official default duration is 8s when neither status nor create provided it. merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds) + // E2E latency for async video: create accept → this discovery of done+url. + // Bill on discovery (status/content), not after further client polls; duration + // must not be only the single discovery hop (~hundreds of ms). + if pending != nil { + if e2e := service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()); e2e > 0 { + merged.Duration = e2e + } + } return &merged } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index e0f6aa3d7..b2cf69c83 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -335,6 +335,43 @@ type GrokVideoPendingBilling struct { VideoResolution string `json:"video_resolution,omitempty"` VideoDurationSeconds int `json:"video_duration_seconds,omitempty"` OriginalModel string `json:"original_model,omitempty"` + // CreatedAt is when the gateway accepted the async create (RFC3339Nano UTC). + // duration_ms for deferred billing is measured from this instant until the + // first official done+video.url observation (status poll or content download), + // not the latency of that single discovery request alone. + CreatedAt string `json:"created_at,omitempty"` +} + +// GrokVideoPendingCreatedAtNow formats a create-accept timestamp for pending billing. +func GrokVideoPendingCreatedAtNow() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +// GrokVideoE2EDuration returns wall time from create accept to discovery of completion. +// Returns 0 when CreatedAt is missing or unparseable (caller keeps poll-only Duration). +func GrokVideoE2EDuration(createdAt string, discoveredAt time.Time) time.Duration { + createdAt = strings.TrimSpace(createdAt) + if createdAt == "" { + return 0 + } + if discoveredAt.IsZero() { + discoveredAt = time.Now() + } + var created time.Time + var err error + if created, err = time.Parse(time.RFC3339Nano, createdAt); err != nil { + if created, err = time.Parse(time.RFC3339, createdAt); err != nil { + return 0 + } + } + if created.IsZero() { + return 0 + } + d := discoveredAt.Sub(created) + if d < 0 { + return 0 + } + return d } func grokVideoPendingBillingKey(requestID string, userID, apiKeyID int64) string { @@ -380,6 +417,12 @@ func (s *OpenAIGatewayService) StoreGrokVideoPendingBilling( if pending.VideoDurationSeconds > 0 { pending.VideoDurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(pending.VideoDurationSeconds) } + // Always stamp create-accept time when missing so deferred duration_ms is E2E. + if strings.TrimSpace(pending.CreatedAt) == "" { + pending.CreatedAt = GrokVideoPendingCreatedAtNow() + } else { + pending.CreatedAt = strings.TrimSpace(pending.CreatedAt) + } payload, err := json.Marshal(pending) if err != nil { return err diff --git a/backend/internal/service/grok_media_video_billing_test.go b/backend/internal/service/grok_media_video_billing_test.go index f3a3957bb..84dc2c107 100644 --- a/backend/internal/service/grok_media_video_billing_test.go +++ b/backend/internal/service/grok_media_video_billing_test.go @@ -2,10 +2,34 @@ package service import ( "testing" + "time" "github.com/stretchr/testify/require" ) +func TestGrokVideoE2EDurationFromCreatedAt(t *testing.T) { + t.Parallel() + created := time.Now().UTC().Add(-45 * time.Second) + d := GrokVideoE2EDuration(created.Format(time.RFC3339Nano), time.Now().UTC()) + require.GreaterOrEqual(t, d, 44*time.Second) + require.LessOrEqual(t, d, 47*time.Second) + + require.Equal(t, time.Duration(0), GrokVideoE2EDuration("", time.Now())) + require.Equal(t, time.Duration(0), GrokVideoE2EDuration("not-a-time", time.Now())) + // Future CreatedAt clamps to zero (clock skew). + require.Equal(t, time.Duration(0), GrokVideoE2EDuration(time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), time.Now())) +} + +func TestGrokVideoPendingCreatedAtStampOnStoreShape(t *testing.T) { + t.Parallel() + // GrokVideoPendingCreatedAtNow must be parseable by GrokVideoE2EDuration. + stamp := GrokVideoPendingCreatedAtNow() + require.NotEmpty(t, stamp) + d := GrokVideoE2EDuration(stamp, time.Now().UTC().Add(2*time.Second)) + require.GreaterOrEqual(t, d, time.Second) + require.LessOrEqual(t, d, 3*time.Second) +} + func TestIsGrokVideoStatusBillable(t *testing.T) { t.Parallel() // Official success: status=done + video.url