fix: bill Grok video per second and harden video usage logging

Follow-up fixes for the #3775 audit findings:

- Bill Grok video generation per second of output, matching the xAI rate
  card: parse the request duration (1-15s, upstream default 8s) and compute
  cost as per-second price x duration x count. The built-in rate card values
  were already xAI per-second prices but were previously charged per video,
  undercharging up to 15x with a user-controlled duration.
- Group video_price_* fields are now documented and surfaced as per-second
  rates (USD/s); admin UI labels, placeholders and hints updated accordingly.
- Persist video_count/video_resolution/video_duration_seconds on usage_logs
  (migration 172) so video billing is auditable, and exempt any row with
  video_count > 0 from the image_size check constraint: a video billed via a
  token-mode channel price produces billing_mode='token' with image_count=1
  and no image_size, which the previous constraint rejected, dropping the
  whole billing transaction.
- Only refetch the group in apiKeyWithFreshGroupMediaPricing when the group
  object actually looks like it is missing media pricing fields (both media
  multipliers zero and all prices nil, impossible for a normally loaded
  group), removing a per-usage DB query for groups without overrides.
- Frontend: drop the unused admin.groups.mediaPricing locale block, map
  cleared price inputs to null (create) / -1 (update, cleared via backend
  normalizePrice) instead of sending "" that failed *float64 unmarshalling,
  and align video price placeholders with the text-to-video default model
  (grok-imagine-video 0.05/0.07, 1080p only on 1.5 at 0.25).
This commit is contained in:
shaw
2026-07-09 15:38:59 +08:00
parent 9ba0fb3084
commit d4952154ff
28 changed files with 1396 additions and 143 deletions
+17 -14
View File
@@ -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]},
},
},
}
+268 -1
View File
@@ -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
+10 -2
View File
@@ -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()
+14
View File
@@ -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),
+41 -2
View File
@@ -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(", ")
+28
View File
@@ -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()
+180
View File
@@ -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))
+266
View File
@@ -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) {
+188
View File
@@ -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)
}
@@ -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'",
@@ -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,
@@ -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
}
@@ -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{},
+15 -11
View File
@@ -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)
}
@@ -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)
+44 -32
View File
@@ -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
}
@@ -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) {
@@ -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}
@@ -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
@@ -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
+5
View File
@@ -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
@@ -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":
@@ -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 平台使用';
+4 -15
View File
@@ -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: {
+4 -14
View File
@@ -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-video480p $0.05/s、720p $0.07/svideo-1.5480p $0.08/s、720p $0.14/s、1080p $0.25/s',
independentMultiplier: '视频倍率独立',
videoMultiplier: '视频独立倍率',
modeHint:
'默认关闭独立倍率时,视频费用 = 视频价格 × 当前分组有效倍率;开启独立倍率后,视频费用 = 视频价格 × 视频独立倍率。',
finalPricePreview: '最终单次视频价格预览',
'视频按秒计费:费用 = 每秒价格 × 时长(1-15 秒,未指定默认 8 秒)。默认叠加当前分组有效倍率;开启独立倍率后改用视频独立倍率。',
finalPricePreview: '最终每秒价格预览',
notConfigured: '未配置'
},
peakRate: {
+24 -6
View File
@@ -1010,7 +1010,7 @@
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($)</label>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="createForm.video_price_480p"
type="number"
@@ -1021,7 +1021,7 @@
/>
</div>
<div>
<label class="input-label">720p ($)</label>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="createForm.video_price_720p"
type="number"
@@ -1032,7 +1032,7 @@
/>
</div>
<div>
<label class="input-label">1080p ($)</label>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="createForm.video_price_1080p"
type="number"
@@ -2489,7 +2489,7 @@
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($)</label>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="editForm.video_price_480p"
type="number"
@@ -2500,7 +2500,7 @@
/>
</div>
<div>
<label class="input-label">720p ($)</label>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="editForm.video_price_720p"
type="number"
@@ -2511,7 +2511,7 @@
/>
</div>
<div>
<label class="input-label">1080p ($)</label>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="editForm.video_price_1080p"
type="number"
@@ -4718,6 +4718,14 @@ const handleCreateGroup = async () => {
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;
@@ -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");
});
@@ -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<VideoPricingTierKey, string>
> = {
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",
},
};