fix(grok): set async video duration_ms from create accept to done discovery

Store CreatedAt on pending billing at video create and use wall-clock E2E
latency when status/content first observes official done+video.url, so usage
logs no longer record only the single poll hop.
This commit is contained in:
IanShaw027 committed 2026-08-08 08:48:38 +08:00
1 parent 165b072908
commit 85b65284ec
3 files changed
+81

No files matched your search

+14
View File
@@ -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
}
+43
View File
@@ -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
@@ -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