diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index edae57d212..d3e8bc5448 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1572,6 +1572,9 @@ var ( {Name: "image_output_size", Type: field.TypeString, Nullable: true, Size: 32}, {Name: "image_size_source", Type: field.TypeString, Nullable: true, Size: 16}, {Name: "image_size_breakdown", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "video_count", Type: field.TypeInt, Default: 0}, + {Name: "video_resolution", Type: field.TypeString, Nullable: true, Size: 10}, + {Name: "video_duration_seconds", Type: field.TypeInt, Nullable: true}, {Name: "cache_ttl_overridden", Type: field.TypeBool, Default: false}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "api_key_id", Type: field.TypeInt64}, @@ -1588,31 +1591,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[38]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[39]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -1621,32 +1624,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[37]}, + Columns: []*schema.Column{UsageLogsColumns[40]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[38]}, + Columns: []*schema.Column{UsageLogsColumns[41]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[39]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[39]}, }, { Name: "usagelog_model", @@ -1666,17 +1669,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[39]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[39]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]}, + Columns: []*schema.Column{UsageLogsColumns[42], UsageLogsColumns[39]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 07f8ce623e..8d32773050 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -41712,6 +41712,11 @@ type UsageLogMutation struct { image_output_size *string image_size_source *string image_size_breakdown *map[string]int + video_count *int + addvideo_count *int + video_resolution *string + video_duration_seconds *int + addvideo_duration_seconds *int cache_ttl_overridden *bool created_at *time.Time clearedFields map[string]struct{} @@ -43850,6 +43855,181 @@ func (m *UsageLogMutation) ResetImageSizeBreakdown() { delete(m.clearedFields, usagelog.FieldImageSizeBreakdown) } +// SetVideoCount sets the "video_count" field. +func (m *UsageLogMutation) SetVideoCount(i int) { + m.video_count = &i + m.addvideo_count = nil +} + +// VideoCount returns the value of the "video_count" field in the mutation. +func (m *UsageLogMutation) VideoCount() (r int, exists bool) { + v := m.video_count + if v == nil { + return + } + return *v, true +} + +// OldVideoCount returns the old "video_count" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoCount(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoCount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoCount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoCount: %w", err) + } + return oldValue.VideoCount, nil +} + +// AddVideoCount adds i to the "video_count" field. +func (m *UsageLogMutation) AddVideoCount(i int) { + if m.addvideo_count != nil { + *m.addvideo_count += i + } else { + m.addvideo_count = &i + } +} + +// AddedVideoCount returns the value that was added to the "video_count" field in this mutation. +func (m *UsageLogMutation) AddedVideoCount() (r int, exists bool) { + v := m.addvideo_count + if v == nil { + return + } + return *v, true +} + +// ResetVideoCount resets all changes to the "video_count" field. +func (m *UsageLogMutation) ResetVideoCount() { + m.video_count = nil + m.addvideo_count = nil +} + +// SetVideoResolution sets the "video_resolution" field. +func (m *UsageLogMutation) SetVideoResolution(s string) { + m.video_resolution = &s +} + +// VideoResolution returns the value of the "video_resolution" field in the mutation. +func (m *UsageLogMutation) VideoResolution() (r string, exists bool) { + v := m.video_resolution + if v == nil { + return + } + return *v, true +} + +// OldVideoResolution returns the old "video_resolution" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoResolution(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoResolution is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoResolution requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoResolution: %w", err) + } + return oldValue.VideoResolution, nil +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (m *UsageLogMutation) ClearVideoResolution() { + m.video_resolution = nil + m.clearedFields[usagelog.FieldVideoResolution] = struct{}{} +} + +// VideoResolutionCleared returns if the "video_resolution" field was cleared in this mutation. +func (m *UsageLogMutation) VideoResolutionCleared() bool { + _, ok := m.clearedFields[usagelog.FieldVideoResolution] + return ok +} + +// ResetVideoResolution resets all changes to the "video_resolution" field. +func (m *UsageLogMutation) ResetVideoResolution() { + m.video_resolution = nil + delete(m.clearedFields, usagelog.FieldVideoResolution) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (m *UsageLogMutation) SetVideoDurationSeconds(i int) { + m.video_duration_seconds = &i + m.addvideo_duration_seconds = nil +} + +// VideoDurationSeconds returns the value of the "video_duration_seconds" field in the mutation. +func (m *UsageLogMutation) VideoDurationSeconds() (r int, exists bool) { + v := m.video_duration_seconds + if v == nil { + return + } + return *v, true +} + +// OldVideoDurationSeconds returns the old "video_duration_seconds" field's value of the UsageLog entity. +// If the UsageLog object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UsageLogMutation) OldVideoDurationSeconds(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoDurationSeconds is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoDurationSeconds requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoDurationSeconds: %w", err) + } + return oldValue.VideoDurationSeconds, nil +} + +// AddVideoDurationSeconds adds i to the "video_duration_seconds" field. +func (m *UsageLogMutation) AddVideoDurationSeconds(i int) { + if m.addvideo_duration_seconds != nil { + *m.addvideo_duration_seconds += i + } else { + m.addvideo_duration_seconds = &i + } +} + +// AddedVideoDurationSeconds returns the value that was added to the "video_duration_seconds" field in this mutation. +func (m *UsageLogMutation) AddedVideoDurationSeconds() (r int, exists bool) { + v := m.addvideo_duration_seconds + if v == nil { + return + } + return *v, true +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (m *UsageLogMutation) ClearVideoDurationSeconds() { + m.video_duration_seconds = nil + m.addvideo_duration_seconds = nil + m.clearedFields[usagelog.FieldVideoDurationSeconds] = struct{}{} +} + +// VideoDurationSecondsCleared returns if the "video_duration_seconds" field was cleared in this mutation. +func (m *UsageLogMutation) VideoDurationSecondsCleared() bool { + _, ok := m.clearedFields[usagelog.FieldVideoDurationSeconds] + return ok +} + +// ResetVideoDurationSeconds resets all changes to the "video_duration_seconds" field. +func (m *UsageLogMutation) ResetVideoDurationSeconds() { + m.video_duration_seconds = nil + m.addvideo_duration_seconds = nil + delete(m.clearedFields, usagelog.FieldVideoDurationSeconds) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) { m.cache_ttl_overridden = &b @@ -44091,7 +44271,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 41) + fields := make([]string, 0, 44) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -44209,6 +44389,15 @@ func (m *UsageLogMutation) Fields() []string { if m.image_size_breakdown != nil { fields = append(fields, usagelog.FieldImageSizeBreakdown) } + if m.video_count != nil { + fields = append(fields, usagelog.FieldVideoCount) + } + if m.video_resolution != nil { + fields = append(fields, usagelog.FieldVideoResolution) + } + if m.video_duration_seconds != nil { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } if m.cache_ttl_overridden != nil { fields = append(fields, usagelog.FieldCacheTTLOverridden) } @@ -44301,6 +44490,12 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.ImageSizeSource() case usagelog.FieldImageSizeBreakdown: return m.ImageSizeBreakdown() + case usagelog.FieldVideoCount: + return m.VideoCount() + case usagelog.FieldVideoResolution: + return m.VideoResolution() + case usagelog.FieldVideoDurationSeconds: + return m.VideoDurationSeconds() case usagelog.FieldCacheTTLOverridden: return m.CacheTTLOverridden() case usagelog.FieldCreatedAt: @@ -44392,6 +44587,12 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldImageSizeSource(ctx) case usagelog.FieldImageSizeBreakdown: return m.OldImageSizeBreakdown(ctx) + case usagelog.FieldVideoCount: + return m.OldVideoCount(ctx) + case usagelog.FieldVideoResolution: + return m.OldVideoResolution(ctx) + case usagelog.FieldVideoDurationSeconds: + return m.OldVideoDurationSeconds(ctx) case usagelog.FieldCacheTTLOverridden: return m.OldCacheTTLOverridden(ctx) case usagelog.FieldCreatedAt: @@ -44678,6 +44879,27 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetImageSizeBreakdown(v) return nil + case usagelog.FieldVideoCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoCount(v) + return nil + case usagelog.FieldVideoResolution: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoResolution(v) + return nil + case usagelog.FieldVideoDurationSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoDurationSeconds(v) + return nil case usagelog.FieldCacheTTLOverridden: v, ok := value.(bool) if !ok { @@ -44757,6 +44979,12 @@ func (m *UsageLogMutation) AddedFields() []string { if m.addimage_count != nil { fields = append(fields, usagelog.FieldImageCount) } + if m.addvideo_count != nil { + fields = append(fields, usagelog.FieldVideoCount) + } + if m.addvideo_duration_seconds != nil { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } return fields } @@ -44803,6 +45031,10 @@ func (m *UsageLogMutation) AddedField(name string) (ent.Value, bool) { return m.AddedFirstTokenMs() case usagelog.FieldImageCount: return m.AddedImageCount() + case usagelog.FieldVideoCount: + return m.AddedVideoCount() + case usagelog.FieldVideoDurationSeconds: + return m.AddedVideoDurationSeconds() } return nil, false } @@ -44945,6 +45177,20 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error { } m.AddImageCount(v) return nil + case usagelog.FieldVideoCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoCount(v) + return nil + case usagelog.FieldVideoDurationSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVideoDurationSeconds(v) + return nil } return fmt.Errorf("unknown UsageLog numeric field %s", name) } @@ -45007,6 +45253,12 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldImageSizeBreakdown) { fields = append(fields, usagelog.FieldImageSizeBreakdown) } + if m.FieldCleared(usagelog.FieldVideoResolution) { + fields = append(fields, usagelog.FieldVideoResolution) + } + if m.FieldCleared(usagelog.FieldVideoDurationSeconds) { + fields = append(fields, usagelog.FieldVideoDurationSeconds) + } return fields } @@ -45075,6 +45327,12 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldImageSizeBreakdown: m.ClearImageSizeBreakdown() return nil + case usagelog.FieldVideoResolution: + m.ClearVideoResolution() + return nil + case usagelog.FieldVideoDurationSeconds: + m.ClearVideoDurationSeconds() + return nil } return fmt.Errorf("unknown UsageLog nullable field %s", name) } @@ -45200,6 +45458,15 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldImageSizeBreakdown: m.ResetImageSizeBreakdown() return nil + case usagelog.FieldVideoCount: + m.ResetVideoCount() + return nil + case usagelog.FieldVideoResolution: + m.ResetVideoResolution() + return nil + case usagelog.FieldVideoDurationSeconds: + m.ResetVideoDurationSeconds() + return nil case usagelog.FieldCacheTTLOverridden: m.ResetCacheTTLOverridden() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index ddde08d23d..d47e7d143b 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1976,12 +1976,20 @@ func init() { usagelogDescImageSizeSource := usagelogFields[37].Descriptor() // usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error) + // usagelogDescVideoCount is the schema descriptor for video_count field. + usagelogDescVideoCount := usagelogFields[39].Descriptor() + // usagelog.DefaultVideoCount holds the default value on creation for the video_count field. + usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int) + // usagelogDescVideoResolution is the schema descriptor for video_resolution field. + usagelogDescVideoResolution := usagelogFields[40].Descriptor() + // usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. + usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[42].Descriptor() // usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field. usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[40].Descriptor() + usagelogDescCreatedAt := usagelogFields[43].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index db9e517892..e84cc1c140 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -149,6 +149,20 @@ func (UsageLog) Fields() []ent.Field { field.JSON("image_size_breakdown", map[string]int{}). Optional(). SchemaType(map[string]string{dialect.Postgres: "jsonb"}), + + // 视频生成字段(Grok 视频按秒计费;billing_mode 走 token/其他模式时这些列仍标记视频用量) + field.Int("video_count"). + Default(0). + Comment("视频生成数量;>0 表示本行是视频生成用量"), + field.String("video_resolution"). + MaxLen(10). + Optional(). + Nillable(). + Comment("计费用视频分辨率 480p/720p/1080p"), + field.Int("video_duration_seconds"). + Optional(). + Nillable(). + Comment("提交时请求的视频时长(秒),按秒计费的乘数"), // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) field.Bool("cache_ttl_overridden"). Default(false), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index 283fe828a9..4d374a8495 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -101,6 +101,12 @@ type UsageLog struct { ImageSizeSource *string `json:"image_size_source,omitempty"` // ImageSizeBreakdown holds the value of the "image_size_breakdown" field. ImageSizeBreakdown map[string]int `json:"image_size_breakdown,omitempty"` + // 视频生成数量;>0 表示本行是视频生成用量 + VideoCount int `json:"video_count,omitempty"` + // 计费用视频分辨率 480p/720p/1080p + VideoResolution *string `json:"video_resolution,omitempty"` + // 提交时请求的视频时长(秒),按秒计费的乘数 + VideoDurationSeconds *int `json:"video_duration_seconds,omitempty"` // CacheTTLOverridden holds the value of the "cache_ttl_overridden" field. CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"` // CreatedAt holds the value of the "created_at" field. @@ -194,9 +200,9 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier: values[i] = new(sql.NullFloat64) - case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount: + case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -474,6 +480,26 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field image_size_breakdown: %w", err) } } + case usagelog.FieldVideoCount: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field video_count", values[i]) + } else if value.Valid { + _m.VideoCount = int(value.Int64) + } + case usagelog.FieldVideoResolution: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field video_resolution", values[i]) + } else if value.Valid { + _m.VideoResolution = new(string) + *_m.VideoResolution = value.String + } + case usagelog.FieldVideoDurationSeconds: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field video_duration_seconds", values[i]) + } else if value.Valid { + _m.VideoDurationSeconds = new(int) + *_m.VideoDurationSeconds = int(value.Int64) + } case usagelog.FieldCacheTTLOverridden: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i]) @@ -698,6 +724,19 @@ func (_m *UsageLog) String() string { builder.WriteString("image_size_breakdown=") builder.WriteString(fmt.Sprintf("%v", _m.ImageSizeBreakdown)) builder.WriteString(", ") + builder.WriteString("video_count=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoCount)) + builder.WriteString(", ") + if v := _m.VideoResolution; v != nil { + builder.WriteString("video_resolution=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.VideoDurationSeconds; v != nil { + builder.WriteString("video_duration_seconds=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("cache_ttl_overridden=") builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden)) builder.WriteString(", ") diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index 297e0b41ad..a74a92c40f 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -92,6 +92,12 @@ const ( FieldImageSizeSource = "image_size_source" // FieldImageSizeBreakdown holds the string denoting the image_size_breakdown field in the database. FieldImageSizeBreakdown = "image_size_breakdown" + // FieldVideoCount holds the string denoting the video_count field in the database. + FieldVideoCount = "video_count" + // FieldVideoResolution holds the string denoting the video_resolution field in the database. + FieldVideoResolution = "video_resolution" + // FieldVideoDurationSeconds holds the string denoting the video_duration_seconds field in the database. + FieldVideoDurationSeconds = "video_duration_seconds" // FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database. FieldCacheTTLOverridden = "cache_ttl_overridden" // FieldCreatedAt holds the string denoting the created_at field in the database. @@ -187,6 +193,9 @@ var Columns = []string{ FieldImageOutputSize, FieldImageSizeSource, FieldImageSizeBreakdown, + FieldVideoCount, + FieldVideoResolution, + FieldVideoDurationSeconds, FieldCacheTTLOverridden, FieldCreatedAt, } @@ -260,6 +269,10 @@ var ( ImageOutputSizeValidator func(string) error // ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. ImageSizeSourceValidator func(string) error + // DefaultVideoCount holds the default value on creation for the "video_count" field. + DefaultVideoCount int + // VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. + VideoResolutionValidator func(string) error // DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field. DefaultCacheTTLOverridden bool // DefaultCreatedAt holds the default value on creation for the "created_at" field. @@ -464,6 +477,21 @@ func ByImageSizeSource(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImageSizeSource, opts...).ToFunc() } +// ByVideoCount orders the results by the video_count field. +func ByVideoCount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoCount, opts...).ToFunc() +} + +// ByVideoResolution orders the results by the video_resolution field. +func ByVideoResolution(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoResolution, opts...).ToFunc() +} + +// ByVideoDurationSeconds orders the results by the video_duration_seconds field. +func ByVideoDurationSeconds(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVideoDurationSeconds, opts...).ToFunc() +} + // ByCacheTTLOverridden orders the results by the cache_ttl_overridden field. func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index 2987f17930..4b08cc3425 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -245,6 +245,21 @@ func ImageSizeSource(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v)) } +// VideoCount applies equality check predicate on the "video_count" field. It's identical to VideoCountEQ. +func VideoCount(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v)) +} + +// VideoResolution applies equality check predicate on the "video_resolution" field. It's identical to VideoResolutionEQ. +func VideoResolution(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v)) +} + +// VideoDurationSeconds applies equality check predicate on the "video_duration_seconds" field. It's identical to VideoDurationSecondsEQ. +func VideoDurationSeconds(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v)) +} + // CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ. func CacheTTLOverridden(v bool) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) @@ -2150,6 +2165,171 @@ func ImageSizeBreakdownNotNil() predicate.UsageLog { return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeBreakdown)) } +// VideoCountEQ applies the EQ predicate on the "video_count" field. +func VideoCountEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoCount, v)) +} + +// VideoCountNEQ applies the NEQ predicate on the "video_count" field. +func VideoCountNEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoCount, v)) +} + +// VideoCountIn applies the In predicate on the "video_count" field. +func VideoCountIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoCount, vs...)) +} + +// VideoCountNotIn applies the NotIn predicate on the "video_count" field. +func VideoCountNotIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoCount, vs...)) +} + +// VideoCountGT applies the GT predicate on the "video_count" field. +func VideoCountGT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoCount, v)) +} + +// VideoCountGTE applies the GTE predicate on the "video_count" field. +func VideoCountGTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoCount, v)) +} + +// VideoCountLT applies the LT predicate on the "video_count" field. +func VideoCountLT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoCount, v)) +} + +// VideoCountLTE applies the LTE predicate on the "video_count" field. +func VideoCountLTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoCount, v)) +} + +// VideoResolutionEQ applies the EQ predicate on the "video_resolution" field. +func VideoResolutionEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoResolution, v)) +} + +// VideoResolutionNEQ applies the NEQ predicate on the "video_resolution" field. +func VideoResolutionNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoResolution, v)) +} + +// VideoResolutionIn applies the In predicate on the "video_resolution" field. +func VideoResolutionIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoResolution, vs...)) +} + +// VideoResolutionNotIn applies the NotIn predicate on the "video_resolution" field. +func VideoResolutionNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoResolution, vs...)) +} + +// VideoResolutionGT applies the GT predicate on the "video_resolution" field. +func VideoResolutionGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoResolution, v)) +} + +// VideoResolutionGTE applies the GTE predicate on the "video_resolution" field. +func VideoResolutionGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoResolution, v)) +} + +// VideoResolutionLT applies the LT predicate on the "video_resolution" field. +func VideoResolutionLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoResolution, v)) +} + +// VideoResolutionLTE applies the LTE predicate on the "video_resolution" field. +func VideoResolutionLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoResolution, v)) +} + +// VideoResolutionContains applies the Contains predicate on the "video_resolution" field. +func VideoResolutionContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldVideoResolution, v)) +} + +// VideoResolutionHasPrefix applies the HasPrefix predicate on the "video_resolution" field. +func VideoResolutionHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldVideoResolution, v)) +} + +// VideoResolutionHasSuffix applies the HasSuffix predicate on the "video_resolution" field. +func VideoResolutionHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldVideoResolution, v)) +} + +// VideoResolutionIsNil applies the IsNil predicate on the "video_resolution" field. +func VideoResolutionIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldVideoResolution)) +} + +// VideoResolutionNotNil applies the NotNil predicate on the "video_resolution" field. +func VideoResolutionNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldVideoResolution)) +} + +// VideoResolutionEqualFold applies the EqualFold predicate on the "video_resolution" field. +func VideoResolutionEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldVideoResolution, v)) +} + +// VideoResolutionContainsFold applies the ContainsFold predicate on the "video_resolution" field. +func VideoResolutionContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldVideoResolution, v)) +} + +// VideoDurationSecondsEQ applies the EQ predicate on the "video_duration_seconds" field. +func VideoDurationSecondsEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsNEQ applies the NEQ predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsIn applies the In predicate on the "video_duration_seconds" field. +func VideoDurationSecondsIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldVideoDurationSeconds, vs...)) +} + +// VideoDurationSecondsNotIn applies the NotIn predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNotIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldVideoDurationSeconds, vs...)) +} + +// VideoDurationSecondsGT applies the GT predicate on the "video_duration_seconds" field. +func VideoDurationSecondsGT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsGTE applies the GTE predicate on the "video_duration_seconds" field. +func VideoDurationSecondsGTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsLT applies the LT predicate on the "video_duration_seconds" field. +func VideoDurationSecondsLT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsLTE applies the LTE predicate on the "video_duration_seconds" field. +func VideoDurationSecondsLTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldVideoDurationSeconds, v)) +} + +// VideoDurationSecondsIsNil applies the IsNil predicate on the "video_duration_seconds" field. +func VideoDurationSecondsIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldVideoDurationSeconds)) +} + +// VideoDurationSecondsNotNil applies the NotNil predicate on the "video_duration_seconds" field. +func VideoDurationSecondsNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldVideoDurationSeconds)) +} + // CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field. func CacheTTLOverriddenEQ(v bool) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 17e800f9ca..3326f72fc0 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -525,6 +525,48 @@ func (_c *UsageLogCreate) SetImageSizeBreakdown(v map[string]int) *UsageLogCreat return _c } +// SetVideoCount sets the "video_count" field. +func (_c *UsageLogCreate) SetVideoCount(v int) *UsageLogCreate { + _c.mutation.SetVideoCount(v) + return _c +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoCount(v *int) *UsageLogCreate { + if v != nil { + _c.SetVideoCount(*v) + } + return _c +} + +// SetVideoResolution sets the "video_resolution" field. +func (_c *UsageLogCreate) SetVideoResolution(v string) *UsageLogCreate { + _c.mutation.SetVideoResolution(v) + return _c +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoResolution(v *string) *UsageLogCreate { + if v != nil { + _c.SetVideoResolution(*v) + } + return _c +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_c *UsageLogCreate) SetVideoDurationSeconds(v int) *UsageLogCreate { + _c.mutation.SetVideoDurationSeconds(v) + return _c +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableVideoDurationSeconds(v *int) *UsageLogCreate { + if v != nil { + _c.SetVideoDurationSeconds(*v) + } + return _c +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate { _c.mutation.SetCacheTTLOverridden(v) @@ -677,6 +719,10 @@ func (_c *UsageLogCreate) defaults() { v := usagelog.DefaultImageCount _c.mutation.SetImageCount(v) } + if _, ok := _c.mutation.VideoCount(); !ok { + v := usagelog.DefaultVideoCount + _c.mutation.SetVideoCount(v) + } if _, ok := _c.mutation.CacheTTLOverridden(); !ok { v := usagelog.DefaultCacheTTLOverridden _c.mutation.SetCacheTTLOverridden(v) @@ -817,6 +863,14 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if _, ok := _c.mutation.VideoCount(); !ok { + return &ValidationError{Name: "video_count", err: errors.New(`ent: missing required field "UsageLog.video_count"`)} + } + if v, ok := _c.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _, ok := _c.mutation.CacheTTLOverridden(); !ok { return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)} } @@ -995,6 +1049,18 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value) _node.ImageSizeBreakdown = value } + if value, ok := _c.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + _node.VideoCount = value + } + if value, ok := _c.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + _node.VideoResolution = &value + } + if value, ok := _c.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + _node.VideoDurationSeconds = &value + } if value, ok := _c.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _node.CacheTTLOverridden = value @@ -1830,6 +1896,66 @@ func (u *UsageLogUpsert) ClearImageSizeBreakdown() *UsageLogUpsert { return u } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsert) SetVideoCount(v int) *UsageLogUpsert { + u.Set(usagelog.FieldVideoCount, v) + return u +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoCount() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoCount) + return u +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsert) AddVideoCount(v int) *UsageLogUpsert { + u.Add(usagelog.FieldVideoCount, v) + return u +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsert) SetVideoResolution(v string) *UsageLogUpsert { + u.Set(usagelog.FieldVideoResolution, v) + return u +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoResolution() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoResolution) + return u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsert) ClearVideoResolution() *UsageLogUpsert { + u.SetNull(usagelog.FieldVideoResolution) + return u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsert) SetVideoDurationSeconds(v int) *UsageLogUpsert { + u.Set(usagelog.FieldVideoDurationSeconds, v) + return u +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateVideoDurationSeconds() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldVideoDurationSeconds) + return u +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsert) AddVideoDurationSeconds(v int) *UsageLogUpsert { + u.Add(usagelog.FieldVideoDurationSeconds, v) + return u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsert) ClearVideoDurationSeconds() *UsageLogUpsert { + u.SetNull(usagelog.FieldVideoDurationSeconds) + return u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert { u.Set(usagelog.FieldCacheTTLOverridden, v) @@ -2692,6 +2818,76 @@ func (u *UsageLogUpsertOne) ClearImageSizeBreakdown() *UsageLogUpsertOne { }) } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsertOne) SetVideoCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoCount(v) + }) +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsertOne) AddVideoCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoCount(v) + }) +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoCount() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoCount() + }) +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsertOne) SetVideoResolution(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoResolution(v) + }) +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoResolution() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoResolution() + }) +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsertOne) ClearVideoResolution() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoResolution() + }) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) SetVideoDurationSeconds(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoDurationSeconds(v) + }) +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) AddVideoDurationSeconds(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoDurationSeconds(v) + }) +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateVideoDurationSeconds() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoDurationSeconds() + }) +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsertOne) ClearVideoDurationSeconds() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoDurationSeconds() + }) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -3722,6 +3918,76 @@ func (u *UsageLogUpsertBulk) ClearImageSizeBreakdown() *UsageLogUpsertBulk { }) } +// SetVideoCount sets the "video_count" field. +func (u *UsageLogUpsertBulk) SetVideoCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoCount(v) + }) +} + +// AddVideoCount adds v to the "video_count" field. +func (u *UsageLogUpsertBulk) AddVideoCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoCount(v) + }) +} + +// UpdateVideoCount sets the "video_count" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoCount() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoCount() + }) +} + +// SetVideoResolution sets the "video_resolution" field. +func (u *UsageLogUpsertBulk) SetVideoResolution(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoResolution(v) + }) +} + +// UpdateVideoResolution sets the "video_resolution" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoResolution() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoResolution() + }) +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (u *UsageLogUpsertBulk) ClearVideoResolution() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoResolution() + }) +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) SetVideoDurationSeconds(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetVideoDurationSeconds(v) + }) +} + +// AddVideoDurationSeconds adds v to the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) AddVideoDurationSeconds(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.AddVideoDurationSeconds(v) + }) +} + +// UpdateVideoDurationSeconds sets the "video_duration_seconds" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateVideoDurationSeconds() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateVideoDurationSeconds() + }) +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (u *UsageLogUpsertBulk) ClearVideoDurationSeconds() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearVideoDurationSeconds() + }) +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index e8fa003c63..00a65ccff1 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -811,6 +811,74 @@ func (_u *UsageLogUpdate) ClearImageSizeBreakdown() *UsageLogUpdate { return _u } +// SetVideoCount sets the "video_count" field. +func (_u *UsageLogUpdate) SetVideoCount(v int) *UsageLogUpdate { + _u.mutation.ResetVideoCount() + _u.mutation.SetVideoCount(v) + return _u +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoCount(v *int) *UsageLogUpdate { + if v != nil { + _u.SetVideoCount(*v) + } + return _u +} + +// AddVideoCount adds value to the "video_count" field. +func (_u *UsageLogUpdate) AddVideoCount(v int) *UsageLogUpdate { + _u.mutation.AddVideoCount(v) + return _u +} + +// SetVideoResolution sets the "video_resolution" field. +func (_u *UsageLogUpdate) SetVideoResolution(v string) *UsageLogUpdate { + _u.mutation.SetVideoResolution(v) + return _u +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoResolution(v *string) *UsageLogUpdate { + if v != nil { + _u.SetVideoResolution(*v) + } + return _u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (_u *UsageLogUpdate) ClearVideoResolution() *UsageLogUpdate { + _u.mutation.ClearVideoResolution() + return _u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_u *UsageLogUpdate) SetVideoDurationSeconds(v int) *UsageLogUpdate { + _u.mutation.ResetVideoDurationSeconds() + _u.mutation.SetVideoDurationSeconds(v) + return _u +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdate { + if v != nil { + _u.SetVideoDurationSeconds(*v) + } + return _u +} + +// AddVideoDurationSeconds adds value to the "video_duration_seconds" field. +func (_u *UsageLogUpdate) AddVideoDurationSeconds(v int) *UsageLogUpdate { + _u.mutation.AddVideoDurationSeconds(v) + return _u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (_u *UsageLogUpdate) ClearVideoDurationSeconds() *UsageLogUpdate { + _u.mutation.ClearVideoDurationSeconds() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate { _u.mutation.SetCacheTTLOverridden(v) @@ -979,6 +1047,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if v, ok := _u.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -1210,6 +1283,27 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.ImageSizeBreakdownCleared() { _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) } + if value, ok := _u.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoCount(); ok { + _spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + } + if _u.mutation.VideoResolutionCleared() { + _spec.ClearField(usagelog.FieldVideoResolution, field.TypeString) + } + if value, ok := _u.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok { + _spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if _u.mutation.VideoDurationSecondsCleared() { + _spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } @@ -2157,6 +2251,74 @@ func (_u *UsageLogUpdateOne) ClearImageSizeBreakdown() *UsageLogUpdateOne { return _u } +// SetVideoCount sets the "video_count" field. +func (_u *UsageLogUpdateOne) SetVideoCount(v int) *UsageLogUpdateOne { + _u.mutation.ResetVideoCount() + _u.mutation.SetVideoCount(v) + return _u +} + +// SetNillableVideoCount sets the "video_count" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoCount(v *int) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoCount(*v) + } + return _u +} + +// AddVideoCount adds value to the "video_count" field. +func (_u *UsageLogUpdateOne) AddVideoCount(v int) *UsageLogUpdateOne { + _u.mutation.AddVideoCount(v) + return _u +} + +// SetVideoResolution sets the "video_resolution" field. +func (_u *UsageLogUpdateOne) SetVideoResolution(v string) *UsageLogUpdateOne { + _u.mutation.SetVideoResolution(v) + return _u +} + +// SetNillableVideoResolution sets the "video_resolution" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoResolution(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoResolution(*v) + } + return _u +} + +// ClearVideoResolution clears the value of the "video_resolution" field. +func (_u *UsageLogUpdateOne) ClearVideoResolution() *UsageLogUpdateOne { + _u.mutation.ClearVideoResolution() + return _u +} + +// SetVideoDurationSeconds sets the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) SetVideoDurationSeconds(v int) *UsageLogUpdateOne { + _u.mutation.ResetVideoDurationSeconds() + _u.mutation.SetVideoDurationSeconds(v) + return _u +} + +// SetNillableVideoDurationSeconds sets the "video_duration_seconds" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableVideoDurationSeconds(v *int) *UsageLogUpdateOne { + if v != nil { + _u.SetVideoDurationSeconds(*v) + } + return _u +} + +// AddVideoDurationSeconds adds value to the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) AddVideoDurationSeconds(v int) *UsageLogUpdateOne { + _u.mutation.AddVideoDurationSeconds(v) + return _u +} + +// ClearVideoDurationSeconds clears the value of the "video_duration_seconds" field. +func (_u *UsageLogUpdateOne) ClearVideoDurationSeconds() *UsageLogUpdateOne { + _u.mutation.ClearVideoDurationSeconds() + return _u +} + // SetCacheTTLOverridden sets the "cache_ttl_overridden" field. func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne { _u.mutation.SetCacheTTLOverridden(v) @@ -2338,6 +2500,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)} } } + if v, ok := _u.mutation.VideoResolution(); ok { + if err := usagelog.VideoResolutionValidator(v); err != nil { + return &ValidationError{Name: "video_resolution", err: fmt.Errorf(`ent: validator failed for field "UsageLog.video_resolution": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -2586,6 +2753,27 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.ImageSizeBreakdownCleared() { _spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON) } + if value, ok := _u.mutation.VideoCount(); ok { + _spec.SetField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoCount(); ok { + _spec.AddField(usagelog.FieldVideoCount, field.TypeInt, value) + } + if value, ok := _u.mutation.VideoResolution(); ok { + _spec.SetField(usagelog.FieldVideoResolution, field.TypeString, value) + } + if _u.mutation.VideoResolutionCleared() { + _spec.ClearField(usagelog.FieldVideoResolution, field.TypeString) + } + if value, ok := _u.mutation.VideoDurationSeconds(); ok { + _spec.SetField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedVideoDurationSeconds(); ok { + _spec.AddField(usagelog.FieldVideoDurationSeconds, field.TypeInt, value) + } + if _u.mutation.VideoDurationSecondsCleared() { + _spec.ClearField(usagelog.FieldVideoDurationSeconds, field.TypeInt) + } if value, ok := _u.mutation.CacheTTLOverridden(); ok { _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) } diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 3235c7404a..7ef98dd700 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -49,6 +49,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "image_output_size", "character varying", 32, true) requireColumn(t, tx, "usage_logs", "image_size_source", "character varying", 16, true) requireColumn(t, tx, "usage_logs", "image_size_breakdown", "jsonb", 0, true) + requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false) + requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true) + requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true) requireConstraintDefinitionContains( t, tx, @@ -68,6 +71,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { "image_count", "billing_mode", "'video'", + "video_count", "image_size IS NOT NULL", "'1K'", "'2K'", diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index bb978ddc1a..dfd8969512 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -63,6 +63,9 @@ var usageLogInsertArgTypes = [...]string{ "text", // image_output_size "text", // image_size_source "jsonb", // image_size_breakdown + "integer", // video_count + "text", // video_resolution + "integer", // video_duration_seconds "text", // service_tier "text", // reasoning_effort "text", // inbound_endpoint @@ -252,6 +255,9 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -269,7 +275,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -700,6 +706,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -713,7 +722,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*50) + args := make([]any, 0, len(keys)*53) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -781,6 +790,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -833,6 +845,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -925,6 +940,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -938,7 +956,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*50) + args := make([]any, 0, len(preparedList)*53) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1003,6 +1021,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1055,6 +1076,9 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1115,6 +1139,9 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared image_output_size, image_size_source, image_size_breakdown, + video_count, + video_resolution, + video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, @@ -1132,7 +1159,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1163,6 +1190,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { imageOutputSize := nullString(log.ImageOutputSize) imageSizeSource := nullString(log.ImageSizeSource) imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown) + videoResolution := nullString(log.VideoResolution) + videoDurationSeconds := nullInt(log.VideoDurationSeconds) serviceTier := nullString(log.ServiceTier) reasoningEffort := nullString(log.ReasoningEffort) inboundEndpoint := nullString(log.InboundEndpoint) @@ -1227,6 +1256,9 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { imageOutputSize, imageSizeSource, imageSizeBreakdown, + log.VideoCount, + videoResolution, + videoDurationSeconds, serviceTier, reasoningEffort, inboundEndpoint, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 0ff4aeb936..c178429bab 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -465,6 +465,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e imageOutputSize sql.NullString imageSizeSource sql.NullString imageSizeBreakdown sql.NullString + videoCount int + videoResolution sql.NullString + videoDurationSeconds sql.NullInt64 serviceTier sql.NullString reasoningEffort sql.NullString inboundEndpoint sql.NullString @@ -519,6 +522,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &imageOutputSize, &imageSizeSource, &imageSizeBreakdown, + &videoCount, + &videoResolution, + &videoDurationSeconds, &serviceTier, &reasoningEffort, &inboundEndpoint, @@ -560,6 +566,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e BillingType: int8(billingType), RequestType: service.RequestTypeFromInt16(requestTypeRaw), ImageCount: imageCount, + VideoCount: videoCount, CacheTTLOverridden: cacheTTLOverridden, CreatedAt: createdAt, } @@ -607,6 +614,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e log.ImageSizeSource = &imageSizeSource.String } log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown) + if videoResolution.Valid { + log.VideoResolution = &videoResolution.String + } + if videoDurationSeconds.Valid { + value := int(videoDurationSeconds.Int64) + log.VideoDurationSeconds = &value + } if serviceTier.Valid { log.ServiceTier = &serviceTier.String } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 4a32557e71..c32ad2b63f 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -80,6 +80,9 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // image_output_size sqlmock.AnyArg(), // image_size_source sqlmock.AnyArg(), // image_size_breakdown + sqlmock.AnyArg(), // video_count + sqlmock.AnyArg(), // video_resolution + sqlmock.AnyArg(), // video_duration_seconds sqlmock.AnyArg(), // service_tier sqlmock.AnyArg(), // reasoning_effort sqlmock.AnyArg(), // inbound_endpoint @@ -163,6 +166,9 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // image_output_size sqlmock.AnyArg(), // image_size_source sqlmock.AnyArg(), // image_size_breakdown + sqlmock.AnyArg(), // video_count + sqlmock.AnyArg(), // video_resolution + sqlmock.AnyArg(), // video_duration_seconds serviceTier, sqlmock.AnyArg(), sqlmock.AnyArg(), @@ -799,6 +805,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{Valid: true, String: "3840x2160"}, sql.NullString{Valid: true, String: "output"}, sql.NullString{Valid: true, String: `{"4K":2}`}, + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{}, sql.NullString{}, sql.NullString{}, @@ -867,6 +876,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, @@ -919,6 +931,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "flex"}, sql.NullString{}, sql.NullString{}, @@ -971,6 +986,9 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // image_output_size sql.NullString{}, // image_size_source sql.NullString{}, // image_size_breakdown + 0, // video_count + sql.NullString{}, // video_resolution + sql.NullInt64{}, // video_duration_seconds sql.NullString{Valid: true, String: "priority"}, sql.NullString{}, sql.NullString{}, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a17b68fe12..4b17ab3c07 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1231,11 +1231,11 @@ type ImagePriceConfig struct { Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值) } -// VideoPriceConfig 视频生成计费配置。 +// VideoPriceConfig 视频生成计费配置。所有价格均为**每秒**单价(USD/s),与 xAI 官方计费口径一致。 type VideoPriceConfig struct { - Price480P *float64 // 480p 视频价格(nil 表示使用默认值) - Price720P *float64 // 720p 视频价格(nil 表示使用默认值) - Price1080P *float64 // 1080p 视频价格(nil 表示使用默认值) + Price480P *float64 // 480p 每秒价格(nil 表示使用默认值) + Price720P *float64 // 720p 每秒价格(nil 表示使用默认值) + Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值) } const ( @@ -1246,6 +1246,7 @@ const ( defaultGrokImagineImageQualityPrice1K = 0.05 defaultGrokImagineImageQualityPrice2K = 0.07 + // 视频默认价为 xAI 官方**每秒**输出价格(USD/s),总价 = 每秒价 × 时长(秒)。 defaultGrokImagineVideoPrice480P = 0.05 defaultGrokImagineVideoPrice720P = 0.07 defaultGrokImagineVideo15Price480P = 0.08 @@ -1284,20 +1285,22 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag } } -// CalculateVideoCost 计算视频生成费用。 +// CalculateVideoCost 计算视频生成费用(按秒计费,与 xAI 口径一致)。 // model: 请求的模型名称(用于获取默认价格) // resolution: 视频分辨率 "480p", "720p", "1080p" // videoCount: 生成的视频数量 -// groupConfig: 分组配置的价格(可能为 nil,表示使用默认值) +// durationSeconds: 单个视频时长(秒),<=0 时按上游默认时长计 +// groupConfig: 分组配置的每秒价格(可能为 nil,表示使用默认值) // rateMultiplier: 费率倍数 -func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown { +func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, durationSeconds int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown { if videoCount <= 0 { return &CostBreakdown{} } resolution = NormalizeVideoBillingResolutionOrDefault(resolution) + durationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds) - unitPrice := s.getVideoUnitPrice(model, resolution, groupConfig) - totalCost := unitPrice * float64(videoCount) + perSecondPrice := s.getVideoUnitPrice(model, resolution, groupConfig) + totalCost := perSecondPrice * float64(durationSeconds) * float64(videoCount) if rateMultiplier < 0 { rateMultiplier = 0 @@ -1394,8 +1397,9 @@ func (s *BillingService) getDefaultVideoPrice(model string, resolution string) f } // The bundled LiteLLM schema does not expose an output video generation price. - // Keep the historical model default as the fallback, while letting group-level - // video prices override it independently from image prices. + // Keep the historical model default as the fallback (interpreted as a per-second + // rate; today only Grok models reach video billing, so this path is a safety net), + // while letting group-level video prices override it independently from image prices. return s.getDefaultImagePrice(model, ImageBillingSize2K) } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index f4c4213533..954de478d9 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -878,14 +878,29 @@ func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) { imagePrice := 0.4 videoPrice := 0.08 imageCost := svc.CalculateImageCost("grok-imagine-video", "2K", 1, &ImagePriceConfig{Price2K: &imagePrice}, 1.0) - videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, &VideoPriceConfig{Price480P: &videoPrice}, 0.5) + videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 10, &VideoPriceConfig{Price480P: &videoPrice}, 0.5) require.InDelta(t, 0.4, imageCost.TotalCost, 1e-10) - require.InDelta(t, 0.08, videoCost.TotalCost, 1e-10) - require.InDelta(t, 0.04, videoCost.ActualCost, 1e-10) + require.InDelta(t, 0.8, videoCost.TotalCost, 1e-10) + require.InDelta(t, 0.4, videoCost.ActualCost, 1e-10) require.Equal(t, string(BillingModeVideo), videoCost.BillingMode) } +func TestCalculateVideoCostBillsPerSecond(t *testing.T) { + svc := newTestBillingService() + + oneSecond := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0) + fifteenSeconds := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 15, nil, 1.0) + // duration <=0 时按上游默认 8 秒计费,超出上限按 15 秒收敛。 + defaultDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 0, nil, 1.0) + clampedDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 999, nil, 1.0) + + require.InDelta(t, 0.07, oneSecond.TotalCost, 1e-10) + require.InDelta(t, 0.07*15, fifteenSeconds.TotalCost, 1e-10) + require.InDelta(t, 0.07*8, defaultDuration.TotalCost, 1e-10) + require.InDelta(t, 0.07*15, clampedDuration.TotalCost, 1e-10) +} + func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { svc := newTestBillingService() @@ -903,11 +918,12 @@ func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) { func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) { svc := newTestBillingService() - standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, nil, 1.0) - standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, nil, 1.0) - video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, nil, 1.0) - video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, nil, 1.0) - video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, nil, 1.0) + // 默认价目为 xAI 官方每秒价格,按 1 秒时长验证每秒单价。 + standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 1, nil, 1.0) + standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0) + video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, 1, nil, 1.0) + video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, 1, nil, 1.0) + video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, 1, nil, 1.0) require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10) require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 70e439e46e..40ad54276c 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -43,16 +43,17 @@ func (e GrokMediaEndpoint) IsGenerationRequest() bool { } type GrokMediaRequestInfo struct { - Model string - Prompt string - N int - Size string - SizeTier string - Resolution string - InputImageURLs []string - MaskImageURL string - Uploads []OpenAIImagesUpload - MaskUpload *OpenAIImagesUpload + Model string + Prompt string + N int + Size string + SizeTier string + Resolution string + DurationSeconds int + InputImageURLs []string + MaskImageURL string + Uploads []OpenAIImagesUpload + MaskUpload *OpenAIImagesUpload } func (r GrokMediaRequestInfo) ModerationBody() []byte { @@ -116,6 +117,7 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo info.Size = strings.TrimSpace(info.Size) info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution) + info.DurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(info.DurationSeconds) if info.N <= 0 { info.N = 1 } @@ -130,6 +132,9 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String()) + if duration := gjson.GetBytes(body, "duration"); duration.Exists() && duration.Type == gjson.Number { + info.DurationSeconds = int(duration.Int()) + } if n := gjson.GetBytes(body, "n"); n.Exists() && n.Type == gjson.Number { info.N = int(n.Int()) } @@ -231,6 +236,10 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM info.Size = value case "resolution": info.Resolution = value + case "duration": + if duration, err := strconv.Atoi(value); err == nil { + info.DurationSeconds = duration + } case "n": if n, err := strconv.Atoi(value); err == nil { info.N = n @@ -356,20 +365,21 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) usage := grokMediaUsageFromResponse(endpoint, requestInfo, respBody) return &OpenAIForwardResult{ - RequestID: requestIDHeader, - ResponseID: usage.ResponseID, - Usage: usage.Usage, - Model: requestModel, - BillingModel: requestModel, - UpstreamModel: requestModel, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - ImageCount: usage.ImageCount, - ImageSize: usage.ImageSize, - ImageInputSize: usage.ImageInputSize, - ImageOutputSizes: usage.ImageOutputSizes, - VideoCount: usage.VideoCount, - VideoResolution: usage.VideoResolution, + RequestID: requestIDHeader, + ResponseID: usage.ResponseID, + Usage: usage.Usage, + Model: requestModel, + BillingModel: requestModel, + UpstreamModel: requestModel, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + ImageCount: usage.ImageCount, + ImageSize: usage.ImageSize, + ImageInputSize: usage.ImageInputSize, + ImageOutputSizes: usage.ImageOutputSizes, + VideoCount: usage.VideoCount, + VideoResolution: usage.VideoResolution, + VideoDurationSeconds: usage.VideoDurationSeconds, }, nil } @@ -472,14 +482,15 @@ func normalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string } type grokMediaUsageMetadata struct { - ResponseID string - Usage OpenAIUsage - ImageCount int - ImageSize string - ImageInputSize string - ImageOutputSizes []string - VideoCount int - VideoResolution string + ResponseID string + Usage OpenAIUsage + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSizes []string + VideoCount int + VideoResolution string + VideoDurationSeconds int } func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMediaRequestInfo, responseBody []byte) grokMediaUsageMetadata { @@ -502,6 +513,7 @@ func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMedi meta.ResponseID = extractGrokMediaVideoRequestID(responseBody) meta.VideoCount = 1 meta.VideoResolution = requestInfo.Resolution + meta.VideoDurationSeconds = requestInfo.DurationSeconds // Keep the legacy media-unit counter populated for existing usage displays. meta.ImageCount = 1 } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index c7d1cd552c..e47ffeb6cb 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -337,7 +337,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p"}`) + body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p","duration":10}`) c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -365,7 +365,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) - require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p"}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) require.Equal(t, "video-request-123", result.ResponseID) require.Equal(t, "grok-imagine-video", result.BillingModel) require.Equal(t, 3, result.Usage.InputTokens) @@ -374,6 +374,7 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) require.Empty(t, result.ImageSize) require.Equal(t, 1, result.VideoCount) require.Equal(t, VideoBillingResolution720P, result.VideoResolution) + require.Equal(t, 10, result.VideoDurationSeconds) } func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) { @@ -412,6 +413,8 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"image_url":"data:image/png;base64,aW1n"}}`, string(upstream.lastBody)) require.Equal(t, "video-request-456", result.ResponseID) require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) + // 未指定 duration 时按上游默认 8 秒计费。 + require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds) } func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index c3622a82e9..b9bbd137c6 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1813,14 +1813,15 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "video-request-123", - ResponseID: "video-request-123", - Model: "grok-imagine-video-1.5", - BillingModel: "grok-imagine-video-1.5", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution480P, - Duration: time.Second, + RequestID: "video-request-123", + ResponseID: "video-request-123", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution480P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10126, @@ -1851,6 +1852,11 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { require.InDelta(t, 0.25, usageRepo.lastLog.RateMultiplier, 1e-12) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoResolution) + require.Equal(t, VideoBillingResolution480P, *usageRepo.lastLog.VideoResolution) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, 1, *usageRepo.lastLog.VideoDurationSeconds) } func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing.T) { @@ -1885,11 +1891,15 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) require.Nil(t, usageRepo.lastLog.ImageSize) - require.InDelta(t, 0.14, usageRepo.lastLog.TotalCost, 1e-12) - require.InDelta(t, 0.14, usageRepo.lastLog.ActualCost, 1e-12) + // 结果未携带 duration 时按上游默认 8 秒计费:0.14 USD/s × 8s。 + require.InDelta(t, 0.14*8, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.14*8, usageRepo.lastLog.ActualCost, 1e-12) require.Equal(t, 1, usageRepo.lastLog.ImageCount) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, VideoBillingDefaultDurationSeconds, *usageRepo.lastLog.VideoDurationSeconds) } func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePrice(t *testing.T) { @@ -1945,13 +1955,14 @@ func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePri err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "resp_grok_video_group_price", - Model: "grok-imagine-video", - BillingModel: "grok-imagine-video", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution720P, - Duration: time.Second, + RequestID: "resp_grok_video_group_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10128, @@ -2046,13 +2057,14 @@ func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshot err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "resp_grok_video_hydrated_price", - Model: "grok-imagine-video", - BillingModel: "grok-imagine-video", - ImageCount: 1, - VideoCount: 1, - VideoResolution: VideoBillingResolution720P, - Duration: time.Second, + RequestID: "resp_grok_video_hydrated_price", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 1, + Duration: time.Second, }, APIKey: &APIKey{ ID: 10131, @@ -2075,6 +2087,53 @@ func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshot require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) } +// 视频请求命中渠道 token 计费时走 token 路径;此时行是 billing_mode='token'、image_count=1、 +// image_size=NULL,必须携带 video_count>0 才能通过 usage_logs 的 image_size check 约束 +// (迁移 172),否则整个计费事务会因约束违反而丢失。 +func TestOpenAIGatewayServiceRecordUsage_GrokVideoWithTokenChannelPricingKeepsVideoMetadata(t *testing.T) { + groupID := int64(132) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_video_token_channel", + Model: "grok-imagine-video", + BillingModel: "grok-imagine-video", + ImageCount: 1, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 5, + Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 200}, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10132, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + }, + }, + User: &User{ID: 20132}, + Account: &Account{ID: 30132, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) + require.Nil(t, usageRepo.lastLog.ImageSize) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 1, usageRepo.lastLog.VideoCount) + require.NotNil(t, usageRepo.lastLog.VideoResolution) + require.Equal(t, VideoBillingResolution720P, *usageRepo.lastLog.VideoResolution) + require.NotNil(t, usageRepo.lastLog.VideoDurationSeconds) + require.Equal(t, 5, *usageRepo.lastLog.VideoDurationSeconds) +} + func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) { groupID := int64(123) usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 822a5f65d3..af8ae3ffe5 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -244,6 +244,8 @@ type OpenAIForwardResult struct { ImageSizeBreakdown map[string]int VideoCount int VideoResolution string + // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。 + VideoDurationSeconds int wsReplayInput []json.RawMessage wsReplayInputExists bool diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 569a7a74ba..4a16facccd 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -243,6 +243,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ImageSizeBreakdown: result.ImageSizeBreakdown, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) + if isVideoUsage { + usageLog.VideoCount = result.VideoCount + usageLog.VideoResolution = optionalTrimmedStringPtr(NormalizeVideoBillingResolutionOrDefault(result.VideoResolution)) + videoDurationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) + usageLog.VideoDurationSeconds = &videoDurationSeconds + } if cost != nil { usageLog.InputCost = cost.InputCost usageLog.OutputCost = cost.OutputCost @@ -499,19 +505,21 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( videoCount = 1 } resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution) + durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) groupConfig := videoPriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { apiKey = refreshed groupConfig = videoPriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } } if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { + // 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。 gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, @@ -530,13 +538,16 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err) } - return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, groupConfig, multiplier) + return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey { if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 { return apiKey } + if !groupMediaPricingLooksIncomplete(apiKey.Group) { + return apiKey + } if s == nil || s.channelService == nil || s.channelService.groupRepo == nil { return apiKey } @@ -549,6 +560,24 @@ func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Cont return &clone } +// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体计费字段(例如由不含 +// 这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在数据库中的 +// 默认值均为 1.0,正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、全部媒体 +// 价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条媒体用量都多打一次 DB 查询。 +func groupMediaPricingLooksIncomplete(group *Group) bool { + if group == nil { + return true + } + if group.ImageRateIndependent || group.VideoRateIndependent { + return false + } + if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 { + return false + } + return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil && + group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil +} + func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { if s.resolver == nil || apiKey == nil || apiKey.Group == nil { return nil diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 66cdb62b2b..62e48fc8f9 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -175,6 +175,11 @@ type UsageLog struct { ImageSizeBreakdown map[string]int MediaType *string + // 视频生成字段(Grok 视频按秒计费;video_count>0 的行不要求 image_size) + VideoCount int + VideoResolution *string + VideoDurationSeconds *int + CreatedAt time.Time User *User diff --git a/backend/internal/service/video_billing_resolution.go b/backend/internal/service/video_billing_resolution.go index bca068f097..cb713f6877 100644 --- a/backend/internal/service/video_billing_resolution.go +++ b/backend/internal/service/video_billing_resolution.go @@ -8,6 +8,29 @@ const ( VideoBillingResolution1080P = "1080p" ) +// xAI 视频生成按秒计费,duration 请求参数允许 1-15 秒;未指定时上游默认生成 8 秒。 +// 计费时长必须与上游实际消耗对齐,否则用户可通过拉长 duration 套利(提交时长由用户控制)。 +const ( + VideoBillingMinDurationSeconds = 1 + VideoBillingMaxDurationSeconds = 15 + VideoBillingDefaultDurationSeconds = 8 +) + +// NormalizeVideoBillingDurationSecondsOrDefault 归一化计费用视频时长: +// 未指定(<=0)按上游默认 8 秒计,超出上游允许区间按边界收敛。 +func NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds int) int { + if durationSeconds <= 0 { + return VideoBillingDefaultDurationSeconds + } + if durationSeconds < VideoBillingMinDurationSeconds { + return VideoBillingMinDurationSeconds + } + if durationSeconds > VideoBillingMaxDurationSeconds { + return VideoBillingMaxDurationSeconds + } + return durationSeconds +} + func NormalizeVideoBillingResolutionOrDefault(resolution string) string { switch strings.ToLower(strings.TrimSpace(resolution)) { case "480", "480p", "sd": diff --git a/backend/migrations/172_video_per_second_billing_metadata.sql b/backend/migrations/172_video_per_second_billing_metadata.sql new file mode 100644 index 0000000000..e54b83dafe --- /dev/null +++ b/backend/migrations/172_video_per_second_billing_metadata.sql @@ -0,0 +1,38 @@ +-- Grok video billing is per second of generated output (xAI rate card), so usage +-- rows must record the billed resolution and duration for auditability. The +-- image-size check constraint must also exempt any video row by video_count +-- instead of billing_mode='video' alone: a video request billed through a +-- token-mode channel price produces billing_mode='token' with image_count=1 +-- (legacy media counter) and no image_size, which the previous constraint +-- rejected and silently dropped the whole billing transaction. + +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS video_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS video_resolution VARCHAR(10), + ADD COLUMN IF NOT EXISTS video_duration_seconds INTEGER; + +COMMENT ON COLUMN usage_logs.video_count IS '视频生成数量;>0 表示本行是视频生成用量'; +COMMENT ON COLUMN usage_logs.video_resolution IS '计费用视频分辨率 480p/720p/1080p'; +COMMENT ON COLUMN usage_logs.video_duration_seconds IS '提交时请求的视频时长(秒),按秒计费的乘数'; + +ALTER TABLE usage_logs + DROP CONSTRAINT IF EXISTS usage_logs_image_billing_size_check; + +ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_image_billing_size_check + CHECK ( + image_count <= 0 + OR billing_mode = 'video' + OR COALESCE(video_count, 0) > 0 + OR ( + image_size IS NOT NULL + AND image_size IN ('1K', '2K', '4K', 'mixed') + ) + ) NOT VALID; + +-- Group video prices are per-second rates (USD/s), matching the xAI rate card; +-- total cost = per-second price x duration seconds. Clarify the column docs +-- introduced by migration 170, which read as per-video prices. +COMMENT ON COLUMN groups.video_price_480p IS '480p 视频生成每秒单价 (USD/s),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_720p IS '720p 视频生成每秒单价 (USD/s),Grok 平台使用'; +COMMENT ON COLUMN groups.video_price_1080p IS '1080p 视频生成每秒单价 (USD/s),Grok 平台使用'; diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 545135d3a1..ffe9723126 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -841,26 +841,15 @@ export default { finalPricePreview: 'Final per-image price preview', notConfigured: 'Not configured' }, - mediaPricing: { - title: 'Image / Video Generation Pricing', - description: - 'Configure Grok image and video generation access plus base media prices. Leave empty to use default prices.', - allowImageGeneration: 'Allow image and video generation for this group', - independentMultiplier: 'Use independent media multiplier', - imageMultiplier: 'Media multiplier', - modeHint: - 'By default, Grok media billing uses media price × current effective group multiplier. Independent mode uses media price × media multiplier. One video generation is billed as one media unit.', - finalPricePreview: 'Final per-media-unit price preview', - notConfigured: 'Not configured' - }, videoPricing: { title: 'Video Generation Pricing', - description: 'Configure Grok video generation base prices. Leave empty to use default video prices.', + description: + 'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).', independentMultiplier: 'Use independent video multiplier', videoMultiplier: 'Video multiplier', modeHint: - 'By default, video billing uses video price × current effective group multiplier. Independent mode uses video price × video multiplier.', - finalPricePreview: 'Final per-video price preview', + 'Videos are billed per second: per-second price × duration (1-15s, default 8s). By default the current effective group multiplier applies; independent mode uses the video multiplier instead.', + finalPricePreview: 'Final per-second price preview', notConfigured: 'Not configured' }, peakRate: { diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b56fcde5d0..a40fe4afe2 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -919,25 +919,15 @@ export default { finalPricePreview: '最终单张价格预览', notConfigured: '未配置' }, - mediaPricing: { - title: '图片/视频生成计费', - description: '配置 Grok 图片和视频生成能力及媒体基础单价,留空则使用默认价格', - allowImageGeneration: '允许当前分组生图和视频生成', - independentMultiplier: '媒体倍率独立', - imageMultiplier: '媒体独立倍率', - modeHint: - '默认关闭独立倍率时,Grok 媒体费用 = 媒体价格 × 当前分组有效倍率;开启独立倍率后,Grok 媒体费用 = 媒体价格 × 媒体独立倍率。一次视频生成按 1 个媒体单位计费。', - finalPricePreview: '最终单次媒体价格预览', - notConfigured: '未配置' - }, videoPricing: { title: '视频生成计费', - description: '配置 Grok 视频生成基础单价,留空则使用默认视频价格', + description: + '配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)', independentMultiplier: '视频倍率独立', videoMultiplier: '视频独立倍率', modeHint: - '默认关闭独立倍率时,视频费用 = 视频价格 × 当前分组有效倍率;开启独立倍率后,视频费用 = 视频价格 × 视频独立倍率。', - finalPricePreview: '最终单次视频价格预览', + '视频按秒计费:费用 = 每秒价格 × 时长(1-15 秒,未指定默认 8 秒)。默认叠加当前分组有效倍率;开启独立倍率后改用视频独立倍率。', + finalPricePreview: '最终每秒价格预览', notConfigured: '未配置' }, peakRate: { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index ef75e6ab97..1f22216100 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1010,7 +1010,7 @@
- +
- +
- +
- +
- +
- + { requestData.video_rate_multiplier = normalizeRateMultiplier( requestData.video_rate_multiplier, ); + // 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400), + // 创建时按"未配置"(null)处理。 + requestData.image_price_1k = emptyToNull(requestData.image_price_1k); + requestData.image_price_2k = emptyToNull(requestData.image_price_2k); + requestData.image_price_4k = emptyToNull(requestData.image_price_4k); + requestData.video_price_480p = emptyToNull(requestData.video_price_480p); + requestData.video_price_720p = emptyToNull(requestData.video_price_720p); + requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p); requestData.peak_rate_enabled = createForm.peak_rate_enabled; requestData.peak_start = createForm.peak_start; requestData.peak_end = createForm.peak_end; @@ -4896,6 +4904,16 @@ const handleUpdateGroup = async () => { payload.video_rate_multiplier = normalizeRateMultiplier( payload.video_rate_multiplier, ); + // 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400)。 + // 更新语义中 null 表示"不修改",因此清空后的字段发送 -1:后端 normalizePrice 将负价归一为 + // NULL,从而真正清除已配置的价格。 + const emptyPriceToClear = (v: any) => (v === "" || v === null ? -1 : v); + payload.image_price_1k = emptyPriceToClear(payload.image_price_1k); + payload.image_price_2k = emptyPriceToClear(payload.image_price_2k); + payload.image_price_4k = emptyPriceToClear(payload.image_price_4k); + payload.video_price_480p = emptyPriceToClear(payload.video_price_480p); + payload.video_price_720p = emptyPriceToClear(payload.video_price_720p); + payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p); payload.peak_rate_enabled = editForm.peak_rate_enabled; payload.peak_start = editForm.peak_start; payload.peak_end = editForm.peak_end; diff --git a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts index e83e22319d..eadd5d711c 100644 --- a/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts @@ -37,8 +37,10 @@ describe("groups image pricing platform support", () => { it("uses Grok media defaults instead of generic image fallback placeholders", () => { expect(getImagePricePlaceholder("grok", "image_price_1k")).toBe("0.02"); expect(getImagePricePlaceholder("grok", "image_price_2k")).toBe("0.02"); - expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.08"); - expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.14"); + // 视频 placeholder 为每秒单价:480p/720p 取 grok-imagine-video 官方每秒价, + // 1080p 仅 video-1.5 支持、取 1.5 每秒价。 + expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.05"); + expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.07"); expect(getVideoPricePlaceholder("grok", "video_price_1080p")).toBe("0.25"); }); diff --git a/frontend/src/views/admin/groupsImagePricing.ts b/frontend/src/views/admin/groupsImagePricing.ts index d88c479995..7ac4fafd07 100644 --- a/frontend/src/views/admin/groupsImagePricing.ts +++ b/frontend/src/views/admin/groupsImagePricing.ts @@ -39,13 +39,15 @@ const defaultImagePricePlaceholders: Record< }, }; +// 视频价为每秒单价(USD/s)。480p/720p 取 grok-imagine-video(文生视频实际走该模型)的 +// 官方每秒价;1080p 仅 grok-imagine-video-1.5 图生视频支持,取 1.5 的每秒价。 const defaultVideoPricePlaceholders: Record< string, Record > = { grok: { - video_price_480p: "0.08", - video_price_720p: "0.14", + video_price_480p: "0.05", + video_price_720p: "0.07", video_price_1080p: "0.25", }, };