Fix image billing size normalization

This commit is contained in:
2ue 2026-05-12 15:21:31 +08:00
parent 62ccd0ff39
commit bb4c1abe28
45 changed files with 3270 additions and 313 deletions

View File

@ -1318,6 +1318,10 @@ var (
{Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45}, {Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45},
{Name: "image_count", Type: field.TypeInt, Default: 0}, {Name: "image_count", Type: field.TypeInt, Default: 0},
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
{Name: "image_input_size", Type: field.TypeString, Nullable: true, Size: 32},
{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: "cache_ttl_overridden", Type: field.TypeBool, Default: false}, {Name: "cache_ttl_overridden", Type: field.TypeBool, Default: false},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "api_key_id", Type: field.TypeInt64}, {Name: "api_key_id", Type: field.TypeInt64},
@ -1334,31 +1338,31 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "usage_logs_api_keys_usage_logs", Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[33]}, Columns: []*schema.Column{UsageLogsColumns[37]},
RefColumns: []*schema.Column{APIKeysColumns[0]}, RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_accounts_usage_logs", Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[34]}, Columns: []*schema.Column{UsageLogsColumns[38]},
RefColumns: []*schema.Column{AccountsColumns[0]}, RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_groups_usage_logs", Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[35]}, Columns: []*schema.Column{UsageLogsColumns[39]},
RefColumns: []*schema.Column{GroupsColumns[0]}, RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
{ {
Symbol: "usage_logs_users_usage_logs", Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[36]}, Columns: []*schema.Column{UsageLogsColumns[40]},
RefColumns: []*schema.Column{UsersColumns[0]}, RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_user_subscriptions_usage_logs", Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[37]}, Columns: []*schema.Column{UsageLogsColumns[41]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
@ -1367,32 +1371,32 @@ var (
{ {
Name: "usagelog_user_id", Name: "usagelog_user_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[36]}, Columns: []*schema.Column{UsageLogsColumns[40]},
}, },
{ {
Name: "usagelog_api_key_id", Name: "usagelog_api_key_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[33]}, Columns: []*schema.Column{UsageLogsColumns[37]},
}, },
{ {
Name: "usagelog_account_id", Name: "usagelog_account_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[34]}, Columns: []*schema.Column{UsageLogsColumns[38]},
}, },
{ {
Name: "usagelog_group_id", Name: "usagelog_group_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[35]}, Columns: []*schema.Column{UsageLogsColumns[39]},
}, },
{ {
Name: "usagelog_subscription_id", Name: "usagelog_subscription_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[37]}, Columns: []*schema.Column{UsageLogsColumns[41]},
}, },
{ {
Name: "usagelog_created_at", Name: "usagelog_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[32]}, Columns: []*schema.Column{UsageLogsColumns[36]},
}, },
{ {
Name: "usagelog_model", Name: "usagelog_model",
@ -1412,17 +1416,17 @@ var (
{ {
Name: "usagelog_user_id_created_at", Name: "usagelog_user_id_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[36], UsageLogsColumns[32]}, Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]},
}, },
{ {
Name: "usagelog_api_key_id_created_at", Name: "usagelog_api_key_id_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[33], UsageLogsColumns[32]}, Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]},
}, },
{ {
Name: "usagelog_group_id_created_at", Name: "usagelog_group_id_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[35], UsageLogsColumns[32]}, Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]},
}, },
}, },
} }

View File

@ -34260,6 +34260,10 @@ type UsageLogMutation struct {
image_count *int image_count *int
addimage_count *int addimage_count *int
image_size *string image_size *string
image_input_size *string
image_output_size *string
image_size_source *string
image_size_breakdown *map[string]int
cache_ttl_overridden *bool cache_ttl_overridden *bool
created_at *time.Time created_at *time.Time
clearedFields map[string]struct{} clearedFields map[string]struct{}
@ -36202,6 +36206,202 @@ func (m *UsageLogMutation) ResetImageSize() {
delete(m.clearedFields, usagelog.FieldImageSize) delete(m.clearedFields, usagelog.FieldImageSize)
} }
// SetImageInputSize sets the "image_input_size" field.
func (m *UsageLogMutation) SetImageInputSize(s string) {
m.image_input_size = &s
}
// ImageInputSize returns the value of the "image_input_size" field in the mutation.
func (m *UsageLogMutation) ImageInputSize() (r string, exists bool) {
v := m.image_input_size
if v == nil {
return
}
return *v, true
}
// OldImageInputSize returns the old "image_input_size" 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) OldImageInputSize(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageInputSize is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageInputSize requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageInputSize: %w", err)
}
return oldValue.ImageInputSize, nil
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (m *UsageLogMutation) ClearImageInputSize() {
m.image_input_size = nil
m.clearedFields[usagelog.FieldImageInputSize] = struct{}{}
}
// ImageInputSizeCleared returns if the "image_input_size" field was cleared in this mutation.
func (m *UsageLogMutation) ImageInputSizeCleared() bool {
_, ok := m.clearedFields[usagelog.FieldImageInputSize]
return ok
}
// ResetImageInputSize resets all changes to the "image_input_size" field.
func (m *UsageLogMutation) ResetImageInputSize() {
m.image_input_size = nil
delete(m.clearedFields, usagelog.FieldImageInputSize)
}
// SetImageOutputSize sets the "image_output_size" field.
func (m *UsageLogMutation) SetImageOutputSize(s string) {
m.image_output_size = &s
}
// ImageOutputSize returns the value of the "image_output_size" field in the mutation.
func (m *UsageLogMutation) ImageOutputSize() (r string, exists bool) {
v := m.image_output_size
if v == nil {
return
}
return *v, true
}
// OldImageOutputSize returns the old "image_output_size" 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) OldImageOutputSize(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageOutputSize is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageOutputSize requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageOutputSize: %w", err)
}
return oldValue.ImageOutputSize, nil
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (m *UsageLogMutation) ClearImageOutputSize() {
m.image_output_size = nil
m.clearedFields[usagelog.FieldImageOutputSize] = struct{}{}
}
// ImageOutputSizeCleared returns if the "image_output_size" field was cleared in this mutation.
func (m *UsageLogMutation) ImageOutputSizeCleared() bool {
_, ok := m.clearedFields[usagelog.FieldImageOutputSize]
return ok
}
// ResetImageOutputSize resets all changes to the "image_output_size" field.
func (m *UsageLogMutation) ResetImageOutputSize() {
m.image_output_size = nil
delete(m.clearedFields, usagelog.FieldImageOutputSize)
}
// SetImageSizeSource sets the "image_size_source" field.
func (m *UsageLogMutation) SetImageSizeSource(s string) {
m.image_size_source = &s
}
// ImageSizeSource returns the value of the "image_size_source" field in the mutation.
func (m *UsageLogMutation) ImageSizeSource() (r string, exists bool) {
v := m.image_size_source
if v == nil {
return
}
return *v, true
}
// OldImageSizeSource returns the old "image_size_source" 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) OldImageSizeSource(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageSizeSource is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageSizeSource requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageSizeSource: %w", err)
}
return oldValue.ImageSizeSource, nil
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (m *UsageLogMutation) ClearImageSizeSource() {
m.image_size_source = nil
m.clearedFields[usagelog.FieldImageSizeSource] = struct{}{}
}
// ImageSizeSourceCleared returns if the "image_size_source" field was cleared in this mutation.
func (m *UsageLogMutation) ImageSizeSourceCleared() bool {
_, ok := m.clearedFields[usagelog.FieldImageSizeSource]
return ok
}
// ResetImageSizeSource resets all changes to the "image_size_source" field.
func (m *UsageLogMutation) ResetImageSizeSource() {
m.image_size_source = nil
delete(m.clearedFields, usagelog.FieldImageSizeSource)
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (m *UsageLogMutation) SetImageSizeBreakdown(value map[string]int) {
m.image_size_breakdown = &value
}
// ImageSizeBreakdown returns the value of the "image_size_breakdown" field in the mutation.
func (m *UsageLogMutation) ImageSizeBreakdown() (r map[string]int, exists bool) {
v := m.image_size_breakdown
if v == nil {
return
}
return *v, true
}
// OldImageSizeBreakdown returns the old "image_size_breakdown" 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) OldImageSizeBreakdown(ctx context.Context) (v map[string]int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageSizeBreakdown is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageSizeBreakdown requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageSizeBreakdown: %w", err)
}
return oldValue.ImageSizeBreakdown, nil
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (m *UsageLogMutation) ClearImageSizeBreakdown() {
m.image_size_breakdown = nil
m.clearedFields[usagelog.FieldImageSizeBreakdown] = struct{}{}
}
// ImageSizeBreakdownCleared returns if the "image_size_breakdown" field was cleared in this mutation.
func (m *UsageLogMutation) ImageSizeBreakdownCleared() bool {
_, ok := m.clearedFields[usagelog.FieldImageSizeBreakdown]
return ok
}
// ResetImageSizeBreakdown resets all changes to the "image_size_breakdown" field.
func (m *UsageLogMutation) ResetImageSizeBreakdown() {
m.image_size_breakdown = nil
delete(m.clearedFields, usagelog.FieldImageSizeBreakdown)
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) { func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) {
m.cache_ttl_overridden = &b m.cache_ttl_overridden = &b
@ -36443,7 +36643,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *UsageLogMutation) Fields() []string { func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 37) fields := make([]string, 0, 41)
if m.user != nil { if m.user != nil {
fields = append(fields, usagelog.FieldUserID) fields = append(fields, usagelog.FieldUserID)
} }
@ -36549,6 +36749,18 @@ func (m *UsageLogMutation) Fields() []string {
if m.image_size != nil { if m.image_size != nil {
fields = append(fields, usagelog.FieldImageSize) fields = append(fields, usagelog.FieldImageSize)
} }
if m.image_input_size != nil {
fields = append(fields, usagelog.FieldImageInputSize)
}
if m.image_output_size != nil {
fields = append(fields, usagelog.FieldImageOutputSize)
}
if m.image_size_source != nil {
fields = append(fields, usagelog.FieldImageSizeSource)
}
if m.image_size_breakdown != nil {
fields = append(fields, usagelog.FieldImageSizeBreakdown)
}
if m.cache_ttl_overridden != nil { if m.cache_ttl_overridden != nil {
fields = append(fields, usagelog.FieldCacheTTLOverridden) fields = append(fields, usagelog.FieldCacheTTLOverridden)
} }
@ -36633,6 +36845,14 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.ImageCount() return m.ImageCount()
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
return m.ImageSize() return m.ImageSize()
case usagelog.FieldImageInputSize:
return m.ImageInputSize()
case usagelog.FieldImageOutputSize:
return m.ImageOutputSize()
case usagelog.FieldImageSizeSource:
return m.ImageSizeSource()
case usagelog.FieldImageSizeBreakdown:
return m.ImageSizeBreakdown()
case usagelog.FieldCacheTTLOverridden: case usagelog.FieldCacheTTLOverridden:
return m.CacheTTLOverridden() return m.CacheTTLOverridden()
case usagelog.FieldCreatedAt: case usagelog.FieldCreatedAt:
@ -36716,6 +36936,14 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldImageCount(ctx) return m.OldImageCount(ctx)
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
return m.OldImageSize(ctx) return m.OldImageSize(ctx)
case usagelog.FieldImageInputSize:
return m.OldImageInputSize(ctx)
case usagelog.FieldImageOutputSize:
return m.OldImageOutputSize(ctx)
case usagelog.FieldImageSizeSource:
return m.OldImageSizeSource(ctx)
case usagelog.FieldImageSizeBreakdown:
return m.OldImageSizeBreakdown(ctx)
case usagelog.FieldCacheTTLOverridden: case usagelog.FieldCacheTTLOverridden:
return m.OldCacheTTLOverridden(ctx) return m.OldCacheTTLOverridden(ctx)
case usagelog.FieldCreatedAt: case usagelog.FieldCreatedAt:
@ -36974,6 +37202,34 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
} }
m.SetImageSize(v) m.SetImageSize(v)
return nil return nil
case usagelog.FieldImageInputSize:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageInputSize(v)
return nil
case usagelog.FieldImageOutputSize:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageOutputSize(v)
return nil
case usagelog.FieldImageSizeSource:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageSizeSource(v)
return nil
case usagelog.FieldImageSizeBreakdown:
v, ok := value.(map[string]int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageSizeBreakdown(v)
return nil
case usagelog.FieldCacheTTLOverridden: case usagelog.FieldCacheTTLOverridden:
v, ok := value.(bool) v, ok := value.(bool)
if !ok { if !ok {
@ -37291,6 +37547,18 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldImageSize) { if m.FieldCleared(usagelog.FieldImageSize) {
fields = append(fields, usagelog.FieldImageSize) fields = append(fields, usagelog.FieldImageSize)
} }
if m.FieldCleared(usagelog.FieldImageInputSize) {
fields = append(fields, usagelog.FieldImageInputSize)
}
if m.FieldCleared(usagelog.FieldImageOutputSize) {
fields = append(fields, usagelog.FieldImageOutputSize)
}
if m.FieldCleared(usagelog.FieldImageSizeSource) {
fields = append(fields, usagelog.FieldImageSizeSource)
}
if m.FieldCleared(usagelog.FieldImageSizeBreakdown) {
fields = append(fields, usagelog.FieldImageSizeBreakdown)
}
return fields return fields
} }
@ -37347,6 +37615,18 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
m.ClearImageSize() m.ClearImageSize()
return nil return nil
case usagelog.FieldImageInputSize:
m.ClearImageInputSize()
return nil
case usagelog.FieldImageOutputSize:
m.ClearImageOutputSize()
return nil
case usagelog.FieldImageSizeSource:
m.ClearImageSizeSource()
return nil
case usagelog.FieldImageSizeBreakdown:
m.ClearImageSizeBreakdown()
return nil
} }
return fmt.Errorf("unknown UsageLog nullable field %s", name) return fmt.Errorf("unknown UsageLog nullable field %s", name)
} }
@ -37460,6 +37740,18 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
m.ResetImageSize() m.ResetImageSize()
return nil return nil
case usagelog.FieldImageInputSize:
m.ResetImageInputSize()
return nil
case usagelog.FieldImageOutputSize:
m.ResetImageOutputSize()
return nil
case usagelog.FieldImageSizeSource:
m.ResetImageSizeSource()
return nil
case usagelog.FieldImageSizeBreakdown:
m.ResetImageSizeBreakdown()
return nil
case usagelog.FieldCacheTTLOverridden: case usagelog.FieldCacheTTLOverridden:
m.ResetCacheTTLOverridden() m.ResetCacheTTLOverridden()
return nil return nil

View File

@ -1722,12 +1722,24 @@ func init() {
usagelogDescImageSize := usagelogFields[34].Descriptor() usagelogDescImageSize := usagelogFields[34].Descriptor()
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
usagelogDescImageInputSize := usagelogFields[35].Descriptor()
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
usagelogDescImageOutputSize := usagelogFields[36].Descriptor()
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
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)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
usagelogDescCacheTTLOverridden := usagelogFields[35].Descriptor() usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor()
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field. // usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool) usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
// usagelogDescCreatedAt is the schema descriptor for created_at field. // usagelogDescCreatedAt is the schema descriptor for created_at field.
usagelogDescCreatedAt := usagelogFields[36].Descriptor() usagelogDescCreatedAt := usagelogFields[40].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin() userMixin := schema.User{}.Mixin()

View File

@ -134,6 +134,21 @@ func (UsageLog) Fields() []ent.Field {
MaxLen(10). MaxLen(10).
Optional(). Optional().
Nillable(), Nillable(),
field.String("image_input_size").
MaxLen(32).
Optional().
Nillable(),
field.String("image_output_size").
MaxLen(32).
Optional().
Nillable(),
field.String("image_size_source").
MaxLen(16).
Optional().
Nillable(),
field.JSON("image_size_breakdown", map[string]int{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
field.Bool("cache_ttl_overridden"). field.Bool("cache_ttl_overridden").
Default(false), Default(false),

View File

@ -3,6 +3,7 @@
package ent package ent
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -92,6 +93,14 @@ type UsageLog struct {
ImageCount int `json:"image_count,omitempty"` ImageCount int `json:"image_count,omitempty"`
// ImageSize holds the value of the "image_size" field. // ImageSize holds the value of the "image_size" field.
ImageSize *string `json:"image_size,omitempty"` ImageSize *string `json:"image_size,omitempty"`
// ImageInputSize holds the value of the "image_input_size" field.
ImageInputSize *string `json:"image_input_size,omitempty"`
// ImageOutputSize holds the value of the "image_output_size" field.
ImageOutputSize *string `json:"image_output_size,omitempty"`
// ImageSizeSource holds the value of the "image_size_source" field.
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"`
// CacheTTLOverridden holds the value of the "cache_ttl_overridden" field. // CacheTTLOverridden holds the value of the "cache_ttl_overridden" field.
CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"` CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"`
// CreatedAt holds the value of the "created_at" field. // CreatedAt holds the value of the "created_at" field.
@ -179,13 +188,15 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns)) values := make([]any, len(columns))
for i := range columns { for i := range columns {
switch columns[i] { switch columns[i] {
case usagelog.FieldImageSizeBreakdown:
values[i] = new([]byte)
case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden: case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
values[i] = new(sql.NullBool) values[i] = new(sql.NullBool)
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier: case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
values[i] = new(sql.NullFloat64) 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:
values[i] = new(sql.NullInt64) 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: 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:
values[i] = new(sql.NullString) values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt: case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
@ -434,6 +445,35 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.ImageSize = new(string) _m.ImageSize = new(string)
*_m.ImageSize = value.String *_m.ImageSize = value.String
} }
case usagelog.FieldImageInputSize:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field image_input_size", values[i])
} else if value.Valid {
_m.ImageInputSize = new(string)
*_m.ImageInputSize = value.String
}
case usagelog.FieldImageOutputSize:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field image_output_size", values[i])
} else if value.Valid {
_m.ImageOutputSize = new(string)
*_m.ImageOutputSize = value.String
}
case usagelog.FieldImageSizeSource:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field image_size_source", values[i])
} else if value.Valid {
_m.ImageSizeSource = new(string)
*_m.ImageSizeSource = value.String
}
case usagelog.FieldImageSizeBreakdown:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field image_size_breakdown", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ImageSizeBreakdown); err != nil {
return fmt.Errorf("unmarshal field image_size_breakdown: %w", err)
}
}
case usagelog.FieldCacheTTLOverridden: case usagelog.FieldCacheTTLOverridden:
if value, ok := values[i].(*sql.NullBool); !ok { if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i]) return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i])
@ -640,6 +680,24 @@ func (_m *UsageLog) String() string {
builder.WriteString(*v) builder.WriteString(*v)
} }
builder.WriteString(", ") builder.WriteString(", ")
if v := _m.ImageInputSize; v != nil {
builder.WriteString("image_input_size=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.ImageOutputSize; v != nil {
builder.WriteString("image_output_size=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.ImageSizeSource; v != nil {
builder.WriteString("image_size_source=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("image_size_breakdown=")
builder.WriteString(fmt.Sprintf("%v", _m.ImageSizeBreakdown))
builder.WriteString(", ")
builder.WriteString("cache_ttl_overridden=") builder.WriteString("cache_ttl_overridden=")
builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden)) builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden))
builder.WriteString(", ") builder.WriteString(", ")

View File

@ -84,6 +84,14 @@ const (
FieldImageCount = "image_count" FieldImageCount = "image_count"
// FieldImageSize holds the string denoting the image_size field in the database. // FieldImageSize holds the string denoting the image_size field in the database.
FieldImageSize = "image_size" FieldImageSize = "image_size"
// FieldImageInputSize holds the string denoting the image_input_size field in the database.
FieldImageInputSize = "image_input_size"
// FieldImageOutputSize holds the string denoting the image_output_size field in the database.
FieldImageOutputSize = "image_output_size"
// FieldImageSizeSource holds the string denoting the image_size_source field in the database.
FieldImageSizeSource = "image_size_source"
// FieldImageSizeBreakdown holds the string denoting the image_size_breakdown field in the database.
FieldImageSizeBreakdown = "image_size_breakdown"
// FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database. // FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database.
FieldCacheTTLOverridden = "cache_ttl_overridden" FieldCacheTTLOverridden = "cache_ttl_overridden"
// FieldCreatedAt holds the string denoting the created_at field in the database. // FieldCreatedAt holds the string denoting the created_at field in the database.
@ -175,6 +183,10 @@ var Columns = []string{
FieldIPAddress, FieldIPAddress,
FieldImageCount, FieldImageCount,
FieldImageSize, FieldImageSize,
FieldImageInputSize,
FieldImageOutputSize,
FieldImageSizeSource,
FieldImageSizeBreakdown,
FieldCacheTTLOverridden, FieldCacheTTLOverridden,
FieldCreatedAt, FieldCreatedAt,
} }
@ -242,6 +254,12 @@ var (
DefaultImageCount int DefaultImageCount int
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. // ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
ImageSizeValidator func(string) error ImageSizeValidator func(string) error
// ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
ImageInputSizeValidator func(string) error
// ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
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
// DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field. // DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field.
DefaultCacheTTLOverridden bool DefaultCacheTTLOverridden bool
// DefaultCreatedAt holds the default value on creation for the "created_at" field. // DefaultCreatedAt holds the default value on creation for the "created_at" field.
@ -431,6 +449,21 @@ func ByImageSize(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageSize, opts...).ToFunc() return sql.OrderByField(FieldImageSize, opts...).ToFunc()
} }
// ByImageInputSize orders the results by the image_input_size field.
func ByImageInputSize(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageInputSize, opts...).ToFunc()
}
// ByImageOutputSize orders the results by the image_output_size field.
func ByImageOutputSize(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageOutputSize, opts...).ToFunc()
}
// ByImageSizeSource orders the results by the image_size_source field.
func ByImageSizeSource(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageSizeSource, opts...).ToFunc()
}
// ByCacheTTLOverridden orders the results by the cache_ttl_overridden field. // ByCacheTTLOverridden orders the results by the cache_ttl_overridden field.
func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption { func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc() return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc()

View File

@ -230,6 +230,21 @@ func ImageSize(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v)) return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v))
} }
// ImageInputSize applies equality check predicate on the "image_input_size" field. It's identical to ImageInputSizeEQ.
func ImageInputSize(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageInputSize, v))
}
// ImageOutputSize applies equality check predicate on the "image_output_size" field. It's identical to ImageOutputSizeEQ.
func ImageOutputSize(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageOutputSize, v))
}
// ImageSizeSource applies equality check predicate on the "image_size_source" field. It's identical to ImageSizeSourceEQ.
func ImageSizeSource(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v))
}
// CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ. // CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ.
func CacheTTLOverridden(v bool) predicate.UsageLog { func CacheTTLOverridden(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
@ -1900,6 +1915,241 @@ func ImageSizeContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v)) return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v))
} }
// ImageInputSizeEQ applies the EQ predicate on the "image_input_size" field.
func ImageInputSizeEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageInputSize, v))
}
// ImageInputSizeNEQ applies the NEQ predicate on the "image_input_size" field.
func ImageInputSizeNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldImageInputSize, v))
}
// ImageInputSizeIn applies the In predicate on the "image_input_size" field.
func ImageInputSizeIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldImageInputSize, vs...))
}
// ImageInputSizeNotIn applies the NotIn predicate on the "image_input_size" field.
func ImageInputSizeNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldImageInputSize, vs...))
}
// ImageInputSizeGT applies the GT predicate on the "image_input_size" field.
func ImageInputSizeGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldImageInputSize, v))
}
// ImageInputSizeGTE applies the GTE predicate on the "image_input_size" field.
func ImageInputSizeGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldImageInputSize, v))
}
// ImageInputSizeLT applies the LT predicate on the "image_input_size" field.
func ImageInputSizeLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldImageInputSize, v))
}
// ImageInputSizeLTE applies the LTE predicate on the "image_input_size" field.
func ImageInputSizeLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldImageInputSize, v))
}
// ImageInputSizeContains applies the Contains predicate on the "image_input_size" field.
func ImageInputSizeContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldImageInputSize, v))
}
// ImageInputSizeHasPrefix applies the HasPrefix predicate on the "image_input_size" field.
func ImageInputSizeHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldImageInputSize, v))
}
// ImageInputSizeHasSuffix applies the HasSuffix predicate on the "image_input_size" field.
func ImageInputSizeHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldImageInputSize, v))
}
// ImageInputSizeIsNil applies the IsNil predicate on the "image_input_size" field.
func ImageInputSizeIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldImageInputSize))
}
// ImageInputSizeNotNil applies the NotNil predicate on the "image_input_size" field.
func ImageInputSizeNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageInputSize))
}
// ImageInputSizeEqualFold applies the EqualFold predicate on the "image_input_size" field.
func ImageInputSizeEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldImageInputSize, v))
}
// ImageInputSizeContainsFold applies the ContainsFold predicate on the "image_input_size" field.
func ImageInputSizeContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldImageInputSize, v))
}
// ImageOutputSizeEQ applies the EQ predicate on the "image_output_size" field.
func ImageOutputSizeEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageOutputSize, v))
}
// ImageOutputSizeNEQ applies the NEQ predicate on the "image_output_size" field.
func ImageOutputSizeNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldImageOutputSize, v))
}
// ImageOutputSizeIn applies the In predicate on the "image_output_size" field.
func ImageOutputSizeIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldImageOutputSize, vs...))
}
// ImageOutputSizeNotIn applies the NotIn predicate on the "image_output_size" field.
func ImageOutputSizeNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldImageOutputSize, vs...))
}
// ImageOutputSizeGT applies the GT predicate on the "image_output_size" field.
func ImageOutputSizeGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldImageOutputSize, v))
}
// ImageOutputSizeGTE applies the GTE predicate on the "image_output_size" field.
func ImageOutputSizeGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldImageOutputSize, v))
}
// ImageOutputSizeLT applies the LT predicate on the "image_output_size" field.
func ImageOutputSizeLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldImageOutputSize, v))
}
// ImageOutputSizeLTE applies the LTE predicate on the "image_output_size" field.
func ImageOutputSizeLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldImageOutputSize, v))
}
// ImageOutputSizeContains applies the Contains predicate on the "image_output_size" field.
func ImageOutputSizeContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldImageOutputSize, v))
}
// ImageOutputSizeHasPrefix applies the HasPrefix predicate on the "image_output_size" field.
func ImageOutputSizeHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldImageOutputSize, v))
}
// ImageOutputSizeHasSuffix applies the HasSuffix predicate on the "image_output_size" field.
func ImageOutputSizeHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldImageOutputSize, v))
}
// ImageOutputSizeIsNil applies the IsNil predicate on the "image_output_size" field.
func ImageOutputSizeIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldImageOutputSize))
}
// ImageOutputSizeNotNil applies the NotNil predicate on the "image_output_size" field.
func ImageOutputSizeNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageOutputSize))
}
// ImageOutputSizeEqualFold applies the EqualFold predicate on the "image_output_size" field.
func ImageOutputSizeEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldImageOutputSize, v))
}
// ImageOutputSizeContainsFold applies the ContainsFold predicate on the "image_output_size" field.
func ImageOutputSizeContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldImageOutputSize, v))
}
// ImageSizeSourceEQ applies the EQ predicate on the "image_size_source" field.
func ImageSizeSourceEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSizeSource, v))
}
// ImageSizeSourceNEQ applies the NEQ predicate on the "image_size_source" field.
func ImageSizeSourceNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldImageSizeSource, v))
}
// ImageSizeSourceIn applies the In predicate on the "image_size_source" field.
func ImageSizeSourceIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldImageSizeSource, vs...))
}
// ImageSizeSourceNotIn applies the NotIn predicate on the "image_size_source" field.
func ImageSizeSourceNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldImageSizeSource, vs...))
}
// ImageSizeSourceGT applies the GT predicate on the "image_size_source" field.
func ImageSizeSourceGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldImageSizeSource, v))
}
// ImageSizeSourceGTE applies the GTE predicate on the "image_size_source" field.
func ImageSizeSourceGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldImageSizeSource, v))
}
// ImageSizeSourceLT applies the LT predicate on the "image_size_source" field.
func ImageSizeSourceLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldImageSizeSource, v))
}
// ImageSizeSourceLTE applies the LTE predicate on the "image_size_source" field.
func ImageSizeSourceLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldImageSizeSource, v))
}
// ImageSizeSourceContains applies the Contains predicate on the "image_size_source" field.
func ImageSizeSourceContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldImageSizeSource, v))
}
// ImageSizeSourceHasPrefix applies the HasPrefix predicate on the "image_size_source" field.
func ImageSizeSourceHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldImageSizeSource, v))
}
// ImageSizeSourceHasSuffix applies the HasSuffix predicate on the "image_size_source" field.
func ImageSizeSourceHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldImageSizeSource, v))
}
// ImageSizeSourceIsNil applies the IsNil predicate on the "image_size_source" field.
func ImageSizeSourceIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldImageSizeSource))
}
// ImageSizeSourceNotNil applies the NotNil predicate on the "image_size_source" field.
func ImageSizeSourceNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeSource))
}
// ImageSizeSourceEqualFold applies the EqualFold predicate on the "image_size_source" field.
func ImageSizeSourceEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldImageSizeSource, v))
}
// ImageSizeSourceContainsFold applies the ContainsFold predicate on the "image_size_source" field.
func ImageSizeSourceContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldImageSizeSource, v))
}
// ImageSizeBreakdownIsNil applies the IsNil predicate on the "image_size_breakdown" field.
func ImageSizeBreakdownIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldImageSizeBreakdown))
}
// ImageSizeBreakdownNotNil applies the NotNil predicate on the "image_size_breakdown" field.
func ImageSizeBreakdownNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageSizeBreakdown))
}
// CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field. // CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field.
func CacheTTLOverriddenEQ(v bool) predicate.UsageLog { func CacheTTLOverriddenEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v)) return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))

View File

@ -477,6 +477,54 @@ func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate {
return _c return _c
} }
// SetImageInputSize sets the "image_input_size" field.
func (_c *UsageLogCreate) SetImageInputSize(v string) *UsageLogCreate {
_c.mutation.SetImageInputSize(v)
return _c
}
// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableImageInputSize(v *string) *UsageLogCreate {
if v != nil {
_c.SetImageInputSize(*v)
}
return _c
}
// SetImageOutputSize sets the "image_output_size" field.
func (_c *UsageLogCreate) SetImageOutputSize(v string) *UsageLogCreate {
_c.mutation.SetImageOutputSize(v)
return _c
}
// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableImageOutputSize(v *string) *UsageLogCreate {
if v != nil {
_c.SetImageOutputSize(*v)
}
return _c
}
// SetImageSizeSource sets the "image_size_source" field.
func (_c *UsageLogCreate) SetImageSizeSource(v string) *UsageLogCreate {
_c.mutation.SetImageSizeSource(v)
return _c
}
// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableImageSizeSource(v *string) *UsageLogCreate {
if v != nil {
_c.SetImageSizeSource(*v)
}
return _c
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (_c *UsageLogCreate) SetImageSizeBreakdown(v map[string]int) *UsageLogCreate {
_c.mutation.SetImageSizeBreakdown(v)
return _c
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate { func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate {
_c.mutation.SetCacheTTLOverridden(v) _c.mutation.SetCacheTTLOverridden(v)
@ -754,6 +802,21 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
} }
} }
if v, ok := _c.mutation.ImageInputSize(); ok {
if err := usagelog.ImageInputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)}
}
}
if v, ok := _c.mutation.ImageOutputSize(); ok {
if err := usagelog.ImageOutputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)}
}
}
if v, ok := _c.mutation.ImageSizeSource(); ok {
if err := usagelog.ImageSizeSourceValidator(v); err != nil {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if _, ok := _c.mutation.CacheTTLOverridden(); !ok { if _, ok := _c.mutation.CacheTTLOverridden(); !ok {
return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)} return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)}
} }
@ -916,6 +979,22 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value) _spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
_node.ImageSize = &value _node.ImageSize = &value
} }
if value, ok := _c.mutation.ImageInputSize(); ok {
_spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value)
_node.ImageInputSize = &value
}
if value, ok := _c.mutation.ImageOutputSize(); ok {
_spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value)
_node.ImageOutputSize = &value
}
if value, ok := _c.mutation.ImageSizeSource(); ok {
_spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value)
_node.ImageSizeSource = &value
}
if value, ok := _c.mutation.ImageSizeBreakdown(); ok {
_spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value)
_node.ImageSizeBreakdown = value
}
if value, ok := _c.mutation.CacheTTLOverridden(); ok { if value, ok := _c.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
_node.CacheTTLOverridden = value _node.CacheTTLOverridden = value
@ -1679,6 +1758,78 @@ func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert {
return u return u
} }
// SetImageInputSize sets the "image_input_size" field.
func (u *UsageLogUpsert) SetImageInputSize(v string) *UsageLogUpsert {
u.Set(usagelog.FieldImageInputSize, v)
return u
}
// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageInputSize() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageInputSize)
return u
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (u *UsageLogUpsert) ClearImageInputSize() *UsageLogUpsert {
u.SetNull(usagelog.FieldImageInputSize)
return u
}
// SetImageOutputSize sets the "image_output_size" field.
func (u *UsageLogUpsert) SetImageOutputSize(v string) *UsageLogUpsert {
u.Set(usagelog.FieldImageOutputSize, v)
return u
}
// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageOutputSize() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageOutputSize)
return u
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (u *UsageLogUpsert) ClearImageOutputSize() *UsageLogUpsert {
u.SetNull(usagelog.FieldImageOutputSize)
return u
}
// SetImageSizeSource sets the "image_size_source" field.
func (u *UsageLogUpsert) SetImageSizeSource(v string) *UsageLogUpsert {
u.Set(usagelog.FieldImageSizeSource, v)
return u
}
// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageSizeSource() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageSizeSource)
return u
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (u *UsageLogUpsert) ClearImageSizeSource() *UsageLogUpsert {
u.SetNull(usagelog.FieldImageSizeSource)
return u
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (u *UsageLogUpsert) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsert {
u.Set(usagelog.FieldImageSizeBreakdown, v)
return u
}
// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageSizeBreakdown() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageSizeBreakdown)
return u
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (u *UsageLogUpsert) ClearImageSizeBreakdown() *UsageLogUpsert {
u.SetNull(usagelog.FieldImageSizeBreakdown)
return u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert { func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert {
u.Set(usagelog.FieldCacheTTLOverridden, v) u.Set(usagelog.FieldCacheTTLOverridden, v)
@ -2457,6 +2608,90 @@ func (u *UsageLogUpsertOne) ClearImageSize() *UsageLogUpsertOne {
}) })
} }
// SetImageInputSize sets the "image_input_size" field.
func (u *UsageLogUpsertOne) SetImageInputSize(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageInputSize(v)
})
}
// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageInputSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageInputSize()
})
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (u *UsageLogUpsertOne) ClearImageInputSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageInputSize()
})
}
// SetImageOutputSize sets the "image_output_size" field.
func (u *UsageLogUpsertOne) SetImageOutputSize(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageOutputSize(v)
})
}
// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageOutputSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageOutputSize()
})
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (u *UsageLogUpsertOne) ClearImageOutputSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageOutputSize()
})
}
// SetImageSizeSource sets the "image_size_source" field.
func (u *UsageLogUpsertOne) SetImageSizeSource(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSizeSource(v)
})
}
// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageSizeSource() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSizeSource()
})
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (u *UsageLogUpsertOne) ClearImageSizeSource() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSizeSource()
})
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (u *UsageLogUpsertOne) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSizeBreakdown(v)
})
}
// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageSizeBreakdown() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSizeBreakdown()
})
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (u *UsageLogUpsertOne) ClearImageSizeBreakdown() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSizeBreakdown()
})
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne { func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) { return u.Update(func(s *UsageLogUpsert) {
@ -3403,6 +3638,90 @@ func (u *UsageLogUpsertBulk) ClearImageSize() *UsageLogUpsertBulk {
}) })
} }
// SetImageInputSize sets the "image_input_size" field.
func (u *UsageLogUpsertBulk) SetImageInputSize(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageInputSize(v)
})
}
// UpdateImageInputSize sets the "image_input_size" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageInputSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageInputSize()
})
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (u *UsageLogUpsertBulk) ClearImageInputSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageInputSize()
})
}
// SetImageOutputSize sets the "image_output_size" field.
func (u *UsageLogUpsertBulk) SetImageOutputSize(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageOutputSize(v)
})
}
// UpdateImageOutputSize sets the "image_output_size" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageOutputSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageOutputSize()
})
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (u *UsageLogUpsertBulk) ClearImageOutputSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageOutputSize()
})
}
// SetImageSizeSource sets the "image_size_source" field.
func (u *UsageLogUpsertBulk) SetImageSizeSource(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSizeSource(v)
})
}
// UpdateImageSizeSource sets the "image_size_source" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageSizeSource() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSizeSource()
})
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (u *UsageLogUpsertBulk) ClearImageSizeSource() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSizeSource()
})
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (u *UsageLogUpsertBulk) SetImageSizeBreakdown(v map[string]int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSizeBreakdown(v)
})
}
// UpdateImageSizeBreakdown sets the "image_size_breakdown" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageSizeBreakdown() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSizeBreakdown()
})
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (u *UsageLogUpsertBulk) ClearImageSizeBreakdown() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSizeBreakdown()
})
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk { func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) { return u.Update(func(s *UsageLogUpsert) {

View File

@ -739,6 +739,78 @@ func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate {
return _u return _u
} }
// SetImageInputSize sets the "image_input_size" field.
func (_u *UsageLogUpdate) SetImageInputSize(v string) *UsageLogUpdate {
_u.mutation.SetImageInputSize(v)
return _u
}
// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableImageInputSize(v *string) *UsageLogUpdate {
if v != nil {
_u.SetImageInputSize(*v)
}
return _u
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (_u *UsageLogUpdate) ClearImageInputSize() *UsageLogUpdate {
_u.mutation.ClearImageInputSize()
return _u
}
// SetImageOutputSize sets the "image_output_size" field.
func (_u *UsageLogUpdate) SetImageOutputSize(v string) *UsageLogUpdate {
_u.mutation.SetImageOutputSize(v)
return _u
}
// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableImageOutputSize(v *string) *UsageLogUpdate {
if v != nil {
_u.SetImageOutputSize(*v)
}
return _u
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (_u *UsageLogUpdate) ClearImageOutputSize() *UsageLogUpdate {
_u.mutation.ClearImageOutputSize()
return _u
}
// SetImageSizeSource sets the "image_size_source" field.
func (_u *UsageLogUpdate) SetImageSizeSource(v string) *UsageLogUpdate {
_u.mutation.SetImageSizeSource(v)
return _u
}
// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableImageSizeSource(v *string) *UsageLogUpdate {
if v != nil {
_u.SetImageSizeSource(*v)
}
return _u
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (_u *UsageLogUpdate) ClearImageSizeSource() *UsageLogUpdate {
_u.mutation.ClearImageSizeSource()
return _u
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (_u *UsageLogUpdate) SetImageSizeBreakdown(v map[string]int) *UsageLogUpdate {
_u.mutation.SetImageSizeBreakdown(v)
return _u
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (_u *UsageLogUpdate) ClearImageSizeBreakdown() *UsageLogUpdate {
_u.mutation.ClearImageSizeBreakdown()
return _u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate { func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate {
_u.mutation.SetCacheTTLOverridden(v) _u.mutation.SetCacheTTLOverridden(v)
@ -892,6 +964,21 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
} }
} }
if v, ok := _u.mutation.ImageInputSize(); ok {
if err := usagelog.ImageInputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)}
}
}
if v, ok := _u.mutation.ImageOutputSize(); ok {
if err := usagelog.ImageOutputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)}
}
}
if v, ok := _u.mutation.ImageSizeSource(); ok {
if err := usagelog.ImageSizeSourceValidator(v); err != nil {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
} }
@ -1099,6 +1186,30 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.ImageSizeCleared() { if _u.mutation.ImageSizeCleared() {
_spec.ClearField(usagelog.FieldImageSize, field.TypeString) _spec.ClearField(usagelog.FieldImageSize, field.TypeString)
} }
if value, ok := _u.mutation.ImageInputSize(); ok {
_spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value)
}
if _u.mutation.ImageInputSizeCleared() {
_spec.ClearField(usagelog.FieldImageInputSize, field.TypeString)
}
if value, ok := _u.mutation.ImageOutputSize(); ok {
_spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value)
}
if _u.mutation.ImageOutputSizeCleared() {
_spec.ClearField(usagelog.FieldImageOutputSize, field.TypeString)
}
if value, ok := _u.mutation.ImageSizeSource(); ok {
_spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value)
}
if _u.mutation.ImageSizeSourceCleared() {
_spec.ClearField(usagelog.FieldImageSizeSource, field.TypeString)
}
if value, ok := _u.mutation.ImageSizeBreakdown(); ok {
_spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value)
}
if _u.mutation.ImageSizeBreakdownCleared() {
_spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON)
}
if value, ok := _u.mutation.CacheTTLOverridden(); ok { if value, ok := _u.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
} }
@ -1974,6 +2085,78 @@ func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne {
return _u return _u
} }
// SetImageInputSize sets the "image_input_size" field.
func (_u *UsageLogUpdateOne) SetImageInputSize(v string) *UsageLogUpdateOne {
_u.mutation.SetImageInputSize(v)
return _u
}
// SetNillableImageInputSize sets the "image_input_size" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableImageInputSize(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetImageInputSize(*v)
}
return _u
}
// ClearImageInputSize clears the value of the "image_input_size" field.
func (_u *UsageLogUpdateOne) ClearImageInputSize() *UsageLogUpdateOne {
_u.mutation.ClearImageInputSize()
return _u
}
// SetImageOutputSize sets the "image_output_size" field.
func (_u *UsageLogUpdateOne) SetImageOutputSize(v string) *UsageLogUpdateOne {
_u.mutation.SetImageOutputSize(v)
return _u
}
// SetNillableImageOutputSize sets the "image_output_size" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableImageOutputSize(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetImageOutputSize(*v)
}
return _u
}
// ClearImageOutputSize clears the value of the "image_output_size" field.
func (_u *UsageLogUpdateOne) ClearImageOutputSize() *UsageLogUpdateOne {
_u.mutation.ClearImageOutputSize()
return _u
}
// SetImageSizeSource sets the "image_size_source" field.
func (_u *UsageLogUpdateOne) SetImageSizeSource(v string) *UsageLogUpdateOne {
_u.mutation.SetImageSizeSource(v)
return _u
}
// SetNillableImageSizeSource sets the "image_size_source" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableImageSizeSource(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetImageSizeSource(*v)
}
return _u
}
// ClearImageSizeSource clears the value of the "image_size_source" field.
func (_u *UsageLogUpdateOne) ClearImageSizeSource() *UsageLogUpdateOne {
_u.mutation.ClearImageSizeSource()
return _u
}
// SetImageSizeBreakdown sets the "image_size_breakdown" field.
func (_u *UsageLogUpdateOne) SetImageSizeBreakdown(v map[string]int) *UsageLogUpdateOne {
_u.mutation.SetImageSizeBreakdown(v)
return _u
}
// ClearImageSizeBreakdown clears the value of the "image_size_breakdown" field.
func (_u *UsageLogUpdateOne) ClearImageSizeBreakdown() *UsageLogUpdateOne {
_u.mutation.ClearImageSizeBreakdown()
return _u
}
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field. // SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne { func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne {
_u.mutation.SetCacheTTLOverridden(v) _u.mutation.SetCacheTTLOverridden(v)
@ -2140,6 +2323,21 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
} }
} }
if v, ok := _u.mutation.ImageInputSize(); ok {
if err := usagelog.ImageInputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_input_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_input_size": %w`, err)}
}
}
if v, ok := _u.mutation.ImageOutputSize(); ok {
if err := usagelog.ImageOutputSizeValidator(v); err != nil {
return &ValidationError{Name: "image_output_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_output_size": %w`, err)}
}
}
if v, ok := _u.mutation.ImageSizeSource(); ok {
if err := usagelog.ImageSizeSourceValidator(v); err != nil {
return &ValidationError{Name: "image_size_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size_source": %w`, err)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
} }
@ -2364,6 +2562,30 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.ImageSizeCleared() { if _u.mutation.ImageSizeCleared() {
_spec.ClearField(usagelog.FieldImageSize, field.TypeString) _spec.ClearField(usagelog.FieldImageSize, field.TypeString)
} }
if value, ok := _u.mutation.ImageInputSize(); ok {
_spec.SetField(usagelog.FieldImageInputSize, field.TypeString, value)
}
if _u.mutation.ImageInputSizeCleared() {
_spec.ClearField(usagelog.FieldImageInputSize, field.TypeString)
}
if value, ok := _u.mutation.ImageOutputSize(); ok {
_spec.SetField(usagelog.FieldImageOutputSize, field.TypeString, value)
}
if _u.mutation.ImageOutputSizeCleared() {
_spec.ClearField(usagelog.FieldImageOutputSize, field.TypeString)
}
if value, ok := _u.mutation.ImageSizeSource(); ok {
_spec.SetField(usagelog.FieldImageSizeSource, field.TypeString, value)
}
if _u.mutation.ImageSizeSourceCleared() {
_spec.ClearField(usagelog.FieldImageSizeSource, field.TypeString)
}
if value, ok := _u.mutation.ImageSizeBreakdown(); ok {
_spec.SetField(usagelog.FieldImageSizeBreakdown, field.TypeJSON, value)
}
if _u.mutation.ImageSizeBreakdownCleared() {
_spec.ClearField(usagelog.FieldImageSizeBreakdown, field.TypeJSON)
}
if value, ok := _u.mutation.CacheTTLOverridden(); ok { if value, ok := _u.mutation.CacheTTLOverridden(); ok {
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value) _spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
} }

View File

@ -216,6 +216,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@ -249,6 +251,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@ -278,6 +282,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@ -310,6 +316,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=

View File

@ -600,6 +600,10 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
FirstTokenMs: l.FirstTokenMs, FirstTokenMs: l.FirstTokenMs,
ImageCount: l.ImageCount, ImageCount: l.ImageCount,
ImageSize: l.ImageSize, ImageSize: l.ImageSize,
ImageInputSize: l.ImageInputSize,
ImageOutputSize: l.ImageOutputSize,
ImageSizeSource: l.ImageSizeSource,
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType, MediaType: l.MediaType,
UserAgent: l.UserAgent, UserAgent: l.UserAgent,
CacheTTLOverridden: l.CacheTTLOverridden, CacheTTLOverridden: l.CacheTTLOverridden,

View File

@ -148,6 +148,65 @@ func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *
require.Equal(t, "claude-3", adminDTO.Model) require.Equal(t, "claude-3", adminDTO.Model)
} }
func TestUsageLogFromService_IncludesImageBillingMetadataForUserAndAdmin(t *testing.T) {
t.Parallel()
imageSize := "4K"
inputSize := "1024x1024"
outputSize := "3840x2160"
source := "output"
log := &service.UsageLog{
RequestID: "req_image_metadata",
Model: "gpt-image-2",
ImageCount: 2,
ImageSize: &imageSize,
ImageInputSize: &inputSize,
ImageOutputSize: &outputSize,
ImageSizeSource: &source,
ImageSizeBreakdown: map[string]int{"4K": 2},
}
userDTO := UsageLogFromService(log)
adminDTO := UsageLogFromServiceAdmin(log)
for _, got := range []*UsageLog{userDTO, &adminDTO.UsageLog} {
require.Equal(t, 2, got.ImageCount)
require.NotNil(t, got.ImageSize)
require.Equal(t, imageSize, *got.ImageSize)
require.NotNil(t, got.ImageInputSize)
require.Equal(t, inputSize, *got.ImageInputSize)
require.NotNil(t, got.ImageOutputSize)
require.Equal(t, outputSize, *got.ImageOutputSize)
require.NotNil(t, got.ImageSizeSource)
require.Equal(t, source, *got.ImageSizeSource)
require.Equal(t, map[string]int{"4K": 2}, got.ImageSizeBreakdown)
}
}
func TestUsageLogFromService_PreservesHistoricalMissingImageSize(t *testing.T) {
t.Parallel()
log := &service.UsageLog{
RequestID: "req_legacy_image_missing_size",
Model: "gpt-image-2",
ImageCount: 1,
ImageSize: nil,
}
dto := UsageLogFromService(log)
require.Equal(t, 1, dto.ImageCount)
require.Nil(t, dto.ImageSize)
require.Nil(t, dto.ImageInputSize)
require.Nil(t, dto.ImageOutputSize)
require.Nil(t, dto.ImageSizeSource)
require.Nil(t, dto.ImageSizeBreakdown)
body, err := json.Marshal(dto)
require.NoError(t, err)
require.Contains(t, string(body), `"image_size":null`)
require.NotContains(t, string(body), `"image_size":"2K"`)
}
func f64Ptr(value float64) *float64 { func f64Ptr(value float64) *float64 {
return &value return &value
} }

View File

@ -400,9 +400,13 @@ type UsageLog struct {
FirstTokenMs *int `json:"first_token_ms"` FirstTokenMs *int `json:"first_token_ms"`
// 图片生成字段 // 图片生成字段
ImageCount int `json:"image_count"` ImageCount int `json:"image_count"`
ImageSize *string `json:"image_size"` ImageSize *string `json:"image_size"`
MediaType *string `json:"media_type"` ImageInputSize *string `json:"image_input_size"`
ImageOutputSize *string `json:"image_output_size"`
ImageSizeSource *string `json:"image_size_source"`
ImageSizeBreakdown map[string]int `json:"image_size_breakdown"`
MediaType *string `json:"media_type"`
// User-Agent // User-Agent
UserAgent *string `json:"user_agent"` UserAgent *string `json:"user_agent"`

View File

@ -58,7 +58,7 @@ func TestResolvePageImagePath(t *testing.T) {
if !ok { if !ok {
t.Fatal("expected direct image path to be accepted") t.Fatal("expected direct image path to be accepted")
} }
want := filepath.Join(base, "logo.png") want := mustEvalSymlinks(t, filepath.Join(base, "logo.png"))
if got != want { if got != want {
t.Fatalf("path = %q, want %q", got, want) t.Fatalf("path = %q, want %q", got, want)
} }
@ -67,7 +67,7 @@ func TestResolvePageImagePath(t *testing.T) {
if !ok { if !ok {
t.Fatal("expected nested image path to be accepted") t.Fatal("expected nested image path to be accepted")
} }
want = filepath.Join(base, "images", "logo.png") want = mustEvalSymlinks(t, filepath.Join(base, "images", "logo.png"))
if got != want { if got != want {
t.Fatalf("path = %q, want %q", got, want) t.Fatalf("path = %q, want %q", got, want)
} }
@ -100,3 +100,13 @@ func TestResolvePageImagePathRejectsSymlinkEscape(t *testing.T) {
t.Fatalf("expected symlink escape to be rejected, got %q", got) t.Fatalf("expected symlink escape to be rejected, got %q", got)
} }
} }
func mustEvalSymlinks(t *testing.T, path string) string {
t.Helper()
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("eval symlinks for %q: %v", path, err)
}
return realPath
}

View File

@ -44,6 +44,33 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false)
requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false)
requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false) requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false)
requireColumn(t, tx, "usage_logs", "image_input_size", "character varying", 32, true)
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)
requireConstraintDefinitionContains(
t,
tx,
"usage_logs",
"usage_logs_image_size_source_check",
"image_size_source",
"'output'",
"'input'",
"'default'",
"'legacy'",
)
requireConstraintDefinitionContains(
t,
tx,
"usage_logs",
"usage_logs_image_billing_size_check",
"image_count",
"image_size IS NOT NULL",
"'1K'",
"'2K'",
"'4K'",
"'mixed'",
)
// usage_billing_dedup: billing idempotency narrow table // usage_billing_dedup: billing idempotency narrow table
var usageBillingDedupRegclass sql.NullString var usageBillingDedupRegclass sql.NullString

View File

@ -28,7 +28,7 @@ import (
gocache "github.com/patrickmn/go-cache" gocache "github.com/patrickmn/go-cache"
) )
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, 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, 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"
// usageLogInsertArgTypes must stay in the same order as: // usageLogInsertArgTypes must stay in the same order as:
// 1. prepareUsageLogInsert().args // 1. prepareUsageLogInsert().args
@ -73,6 +73,10 @@ var usageLogInsertArgTypes = [...]string{
"text", // ip_address "text", // ip_address
"integer", // image_count "integer", // image_count
"text", // image_size "text", // image_size
"text", // image_input_size
"text", // image_output_size
"text", // image_size_source
"jsonb", // image_size_breakdown
"text", // service_tier "text", // service_tier
"text", // reasoning_effort "text", // reasoning_effort
"text", // inbound_endpoint "text", // inbound_endpoint
@ -120,6 +124,24 @@ func appendRawUsageLogModelWhereCondition(conditions []string, args []any, model
return conditions, args return conditions, args
} }
func appendUsageLogBillingModeWhereCondition(conditions []string, args []any, billingMode string) ([]string, []any) {
mode := strings.TrimSpace(billingMode)
if mode == "" {
return conditions, args
}
placeholder := fmt.Sprintf("$%d", len(args)+1)
switch service.BillingMode(mode) {
case service.BillingModeImage:
conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR COALESCE(image_count, 0) > 0)", placeholder))
case service.BillingModeToken:
conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))", placeholder))
default:
conditions = append(conditions, fmt.Sprintf("billing_mode = %s", placeholder))
}
args = append(args, mode)
return conditions, args
}
// appendRawUsageLogModelQueryFilter keeps direct model filters on the raw model column for backward // appendRawUsageLogModelQueryFilter keeps direct model filters on the raw model column for backward
// compatibility with historical rows. Requested/upstream analytics must use // compatibility with historical rows. Requested/upstream analytics must use
// resolveModelDimensionExpression instead. // resolveModelDimensionExpression instead.
@ -352,6 +374,10 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -369,7 +395,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
$10, $11, $12, $13, $10, $11, $12, $13,
$14, $15, $16, $17, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $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 $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
) )
ON CONFLICT (request_id, api_key_id) DO NOTHING ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at RETURNING id, created_at
@ -790,6 +816,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -803,7 +833,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
created_at created_at
) AS (VALUES `) ) AS (VALUES `)
args := make([]any, 0, len(keys)*46) args := make([]any, 0, len(keys)*50)
argPos := 1 argPos := 1
for idx, key := range keys { for idx, key := range keys {
if idx > 0 { if idx > 0 {
@ -867,6 +897,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -915,6 +949,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -1003,6 +1041,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -1016,7 +1058,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
created_at created_at
) AS (VALUES `) ) AS (VALUES `)
args := make([]any, 0, len(preparedList)*46) args := make([]any, 0, len(preparedList)*50)
argPos := 1 argPos := 1
for idx, prepared := range preparedList { for idx, prepared := range preparedList {
if idx > 0 { if idx > 0 {
@ -1077,6 +1119,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -1125,6 +1171,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -1181,6 +1231,10 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
ip_address, ip_address,
image_count, image_count,
image_size, image_size,
image_input_size,
image_output_size,
image_size_source,
image_size_breakdown,
service_tier, service_tier,
reasoning_effort, reasoning_effort,
inbound_endpoint, inbound_endpoint,
@ -1198,7 +1252,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
$10, $11, $12, $13, $10, $11, $12, $13,
$14, $15, $16, $17, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $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 $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
) )
ON CONFLICT (request_id, api_key_id) DO NOTHING ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...) `, prepared.args...)
@ -1225,6 +1279,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
userAgent := nullString(log.UserAgent) userAgent := nullString(log.UserAgent)
ipAddress := nullString(log.IPAddress) ipAddress := nullString(log.IPAddress)
imageSize := nullString(log.ImageSize) imageSize := nullString(log.ImageSize)
imageInputSize := nullString(log.ImageInputSize)
imageOutputSize := nullString(log.ImageOutputSize)
imageSizeSource := nullString(log.ImageSizeSource)
imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown)
serviceTier := nullString(log.ServiceTier) serviceTier := nullString(log.ServiceTier)
reasoningEffort := nullString(log.ReasoningEffort) reasoningEffort := nullString(log.ReasoningEffort)
inboundEndpoint := nullString(log.InboundEndpoint) inboundEndpoint := nullString(log.InboundEndpoint)
@ -1285,6 +1343,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
ipAddress, ipAddress,
log.ImageCount, log.ImageCount,
imageSize, imageSize,
imageInputSize,
imageOutputSize,
imageSizeSource,
imageSizeBreakdown,
serviceTier, serviceTier,
reasoningEffort, reasoningEffort,
inboundEndpoint, inboundEndpoint,
@ -2662,10 +2724,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
args = append(args, int16(*filters.BillingType)) args = append(args, int16(*filters.BillingType))
} }
if filters.BillingMode != "" { conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1))
args = append(args, filters.BillingMode)
}
if filters.StartTime != nil { if filters.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filters.StartTime) args = append(args, *filters.StartTime)
@ -3363,10 +3422,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
args = append(args, int16(*filters.BillingType)) args = append(args, int16(*filters.BillingType))
} }
if filters.BillingMode != "" { conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1))
args = append(args, filters.BillingMode)
}
if filters.StartTime != nil { if filters.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filters.StartTime) args = append(args, *filters.StartTime)
@ -4084,6 +4140,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
ipAddress sql.NullString ipAddress sql.NullString
imageCount int imageCount int
imageSize sql.NullString imageSize sql.NullString
imageInputSize sql.NullString
imageOutputSize sql.NullString
imageSizeSource sql.NullString
imageSizeBreakdown sql.NullString
serviceTier sql.NullString serviceTier sql.NullString
reasoningEffort sql.NullString reasoningEffort sql.NullString
inboundEndpoint sql.NullString inboundEndpoint sql.NullString
@ -4134,6 +4194,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&ipAddress, &ipAddress,
&imageCount, &imageCount,
&imageSize, &imageSize,
&imageInputSize,
&imageOutputSize,
&imageSizeSource,
&imageSizeBreakdown,
&serviceTier, &serviceTier,
&reasoningEffort, &reasoningEffort,
&inboundEndpoint, &inboundEndpoint,
@ -4212,6 +4276,16 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
if imageSize.Valid { if imageSize.Valid {
log.ImageSize = &imageSize.String log.ImageSize = &imageSize.String
} }
if imageInputSize.Valid {
log.ImageInputSize = &imageInputSize.String
}
if imageOutputSize.Valid {
log.ImageOutputSize = &imageOutputSize.String
}
if imageSizeSource.Valid {
log.ImageSizeSource = &imageSizeSource.String
}
log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown)
if serviceTier.Valid { if serviceTier.Valid {
log.ServiceTier = &serviceTier.String log.ServiceTier = &serviceTier.String
} }
@ -4378,6 +4452,31 @@ func nullString(v *string) sql.NullString {
return sql.NullString{String: *v, Valid: true} return sql.NullString{String: *v, Valid: true}
} }
func nullStringIntMapJSON(v map[string]int) any {
if len(v) == 0 {
return nil
}
payload, err := json.Marshal(v)
if err != nil {
return nil
}
return string(payload)
}
func stringIntMapFromNullJSON(v sql.NullString) map[string]int {
if !v.Valid || strings.TrimSpace(v.String) == "" {
return nil
}
var out map[string]int
if err := json.Unmarshal([]byte(v.String), &out); err != nil {
return nil
}
if len(out) == 0 {
return nil
}
return out
}
func coalesceTrimmedString(v sql.NullString, fallback string) string { func coalesceTrimmedString(v sql.NullString, fallback string) string {
if v.Valid && strings.TrimSpace(v.String) != "" { if v.Valid && strings.TrimSpace(v.String) != "" {
return v.String return v.String

View File

@ -76,6 +76,10 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
sqlmock.AnyArg(), // ip_address sqlmock.AnyArg(), // ip_address
log.ImageCount, log.ImageCount,
sqlmock.AnyArg(), // image_size sqlmock.AnyArg(), // image_size
sqlmock.AnyArg(), // image_input_size
sqlmock.AnyArg(), // image_output_size
sqlmock.AnyArg(), // image_size_source
sqlmock.AnyArg(), // image_size_breakdown
sqlmock.AnyArg(), // service_tier sqlmock.AnyArg(), // service_tier
sqlmock.AnyArg(), // reasoning_effort sqlmock.AnyArg(), // reasoning_effort
sqlmock.AnyArg(), // inbound_endpoint sqlmock.AnyArg(), // inbound_endpoint
@ -155,6 +159,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
sqlmock.AnyArg(), sqlmock.AnyArg(),
log.ImageCount, log.ImageCount,
sqlmock.AnyArg(), sqlmock.AnyArg(),
sqlmock.AnyArg(), // image_input_size
sqlmock.AnyArg(), // image_output_size
sqlmock.AnyArg(), // image_size_source
sqlmock.AnyArg(), // image_size_breakdown
serviceTier, serviceTier,
sqlmock.AnyArg(), sqlmock.AnyArg(),
sqlmock.AnyArg(), sqlmock.AnyArg(),
@ -230,12 +238,72 @@ func TestPrepareUsageLogInsert_ArgCountMatchesTypes(t *testing.T) {
require.Len(t, prepared.args, len(usageLogInsertArgTypes)) require.Len(t, prepared.args, len(usageLogInsertArgTypes))
} }
func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
imageSize := "4K"
inputSize := "1024x1024"
outputSize := "3840x2160"
source := "output"
prepared := prepareUsageLogInsert(&service.UsageLog{
UserID: 1,
APIKeyID: 2,
AccountID: 3,
RequestID: "req-image-metadata",
Model: "gpt-image-2",
RequestedModel: "gpt-image-2",
ImageCount: 2,
ImageSize: &imageSize,
ImageInputSize: &inputSize,
ImageOutputSize: &outputSize,
ImageSizeSource: &source,
ImageSizeBreakdown: map[string]int{"1K": 1, "4K": 1},
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
})
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[34])
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[35])
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[36])
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[37])
require.JSONEq(t, `{"1K":1,"4K":1}`, prepared.args[38].(string))
}
func TestCoalesceTrimmedString(t *testing.T) { func TestCoalesceTrimmedString(t *testing.T) {
require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{}, "fallback")) require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{}, "fallback"))
require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{Valid: true, String: " "}, "fallback")) require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{Valid: true, String: " "}, "fallback"))
require.Equal(t, "value", coalesceTrimmedString(sql.NullString{Valid: true, String: "value"}, "fallback")) require.Equal(t, "value", coalesceTrimmedString(sql.NullString{Valid: true, String: "value"}, "fallback"))
} }
func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) {
tests := []struct {
name string
billingMode string
wantCondition string
}{
{
name: "image includes legacy image rows",
billingMode: string(service.BillingModeImage),
wantCondition: "(billing_mode = $1 OR COALESCE(image_count, 0) > 0)",
},
{
name: "token includes legacy non-image rows",
billingMode: string(service.BillingModeToken),
wantCondition: "(billing_mode = $1 OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))",
},
{
name: "per request remains exact",
billingMode: string(service.BillingModePerRequest),
wantCondition: "billing_mode = $1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conditions, args := appendUsageLogBillingModeWhereCondition(nil, nil, tt.billingMode)
require.Equal(t, []string{tt.wantCondition}, conditions)
require.Equal(t, []any{tt.billingMode}, args)
})
}
}
func anySliceToDriverValues(values []any) []driver.Value { func anySliceToDriverValues(values []any) []driver.Value {
out := make([]driver.Value, 0, len(values)) out := make([]driver.Value, 0, len(values))
for _, value := range values { for _, value := range values {
@ -528,6 +596,63 @@ func (s usageLogScannerStub) Scan(dest ...any) error {
} }
func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
t.Run("image_size_metadata_is_scanned", func(t *testing.T) {
now := time.Now().UTC()
log, err := scanUsageLog(usageLogScannerStub{values: []any{
int64(4),
int64(13),
int64(23),
int64(33),
sql.NullString{Valid: true, String: "req-image-metadata"},
"gpt-image-2",
sql.NullString{Valid: true, String: "gpt-image-2"},
sql.NullString{},
sql.NullInt64{},
sql.NullInt64{},
0, 0, 0, 0, 0, 0,
0, 0.0, // image_output_tokens, image_output_cost
0.0, 0.0, 0.0, 0.0, 0.8, 0.8,
1.0,
sql.NullFloat64{},
int16(service.BillingTypeBalance),
int16(service.RequestTypeSync),
false,
false,
sql.NullInt64{},
sql.NullInt64{},
sql.NullString{},
sql.NullString{},
2,
sql.NullString{Valid: true, String: "4K"},
sql.NullString{Valid: true, String: "1024x1024"},
sql.NullString{Valid: true, String: "3840x2160"},
sql.NullString{Valid: true, String: "output"},
sql.NullString{Valid: true, String: `{"4K":2}`},
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
false,
sql.NullInt64{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullFloat64{},
now,
}})
require.NoError(t, err)
require.Equal(t, 2, log.ImageCount)
require.NotNil(t, log.ImageSize)
require.Equal(t, "4K", *log.ImageSize)
require.NotNil(t, log.ImageInputSize)
require.Equal(t, "1024x1024", *log.ImageInputSize)
require.NotNil(t, log.ImageOutputSize)
require.Equal(t, "3840x2160", *log.ImageOutputSize)
require.NotNil(t, log.ImageSizeSource)
require.Equal(t, "output", *log.ImageSizeSource)
require.Equal(t, map[string]int{"4K": 2}, log.ImageSizeBreakdown)
})
t.Run("request_type_ws_v2_overrides_legacy", func(t *testing.T) { t.Run("request_type_ws_v2_overrides_legacy", func(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
log, err := scanUsageLog(usageLogScannerStub{values: []any{ log, err := scanUsageLog(usageLogScannerStub{values: []any{
@ -567,6 +692,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, sql.NullString{},
0, 0,
sql.NullString{}, sql.NullString{},
sql.NullString{}, // image_input_size
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
sql.NullString{Valid: true, String: "priority"}, sql.NullString{Valid: true, String: "priority"},
sql.NullString{}, sql.NullString{},
sql.NullString{}, sql.NullString{},
@ -615,6 +744,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, sql.NullString{},
0, 0,
sql.NullString{}, sql.NullString{},
sql.NullString{}, // image_input_size
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
sql.NullString{Valid: true, String: "flex"}, sql.NullString{Valid: true, String: "flex"},
sql.NullString{}, sql.NullString{},
sql.NullString{}, sql.NullString{},
@ -663,6 +796,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, sql.NullString{},
0, 0,
sql.NullString{}, sql.NullString{},
sql.NullString{}, // image_input_size
sql.NullString{}, // image_output_size
sql.NullString{}, // image_size_source
sql.NullString{}, // image_size_breakdown
sql.NullString{Valid: true, String: "priority"}, sql.NullString{Valid: true, String: "priority"},
sql.NullString{}, sql.NullString{},
sql.NullString{}, sql.NullString{},

View File

@ -554,6 +554,10 @@ func TestAPIContracts(t *testing.T) {
"first_token_ms": 50, "first_token_ms": 50,
"image_count": 0, "image_count": 0,
"image_size": null, "image_size": null,
"image_input_size": null,
"image_output_size": null,
"image_size_source": null,
"image_size_breakdown": null,
"media_type": null, "media_type": null,
"cache_ttl_overridden": false, "cache_ttl_overridden": false,
"created_at": "2025-01-02T03:04:05Z", "created_at": "2025-01-02T03:04:05Z",

View File

@ -2094,7 +2094,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
} }
// 解析请求以获取 image_size用于图片计费 // 解析请求以获取 image_size用于图片计费
imageSize := s.extractImageSize(body) imageInputSize := s.extractImageInputSize(body)
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
switch action { switch action {
case "generateContent", "streamGenerateContent": case "generateContent", "streamGenerateContent":
@ -2465,6 +2466,7 @@ handleSuccess:
ClientDisconnect: clientDisconnect, ClientDisconnect: clientDisconnect,
ImageCount: imageCount, ImageCount: imageCount,
ImageSize: imageSize, ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil }, nil
} }
@ -4065,19 +4067,20 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
// extractImageSize 从 Gemini 请求中提取 image_size 参数 // extractImageSize 从 Gemini 请求中提取 image_size 参数
func (s *AntigravityGatewayService) extractImageSize(body []byte) string { func (s *AntigravityGatewayService) extractImageSize(body []byte) string {
return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body))
}
func (s *AntigravityGatewayService) extractImageInputSize(body []byte) string {
var req antigravity.GeminiRequest var req antigravity.GeminiRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
return "2K" // 默认 2K return ""
} }
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)
if size == "1K" || size == "2K" || size == "4K" {
return size
}
} }
return "2K" // 默认 2K return ""
} }
// isImageGenerationModel 判断模型是否为图片生成模型 // isImageGenerationModel 判断模型是否为图片生成模型

View File

@ -809,6 +809,7 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag
if imageCount <= 0 { if imageCount <= 0 {
return &CostBreakdown{} return &CostBreakdown{}
} }
imageSize = NormalizeImageBillingTierOrDefault(imageSize)
// 获取单价 // 获取单价
unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig) unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig)

View File

@ -48,6 +48,21 @@ func TestCalculateImageCost_GroupCustomPricing(t *testing.T) {
require.InDelta(t, 0.30, cost.TotalCost, 0.0001) require.InDelta(t, 0.30, cost.TotalCost, 0.0001)
} }
func TestCalculateImageCost_NormalizesInvalidSizeTo2K(t *testing.T) {
svc := &BillingService{}
price2K := 0.25
groupConfig := &ImagePriceConfig{Price2K: &price2K}
for _, imageSize := range []string{"", "auto", "not-a-size"} {
t.Run(imageSize, func(t *testing.T) {
cost := svc.CalculateImageCost("gemini-3-pro-image", imageSize, 2, groupConfig, 1.0)
require.InDelta(t, 0.50, cost.TotalCost, 0.0001)
require.InDelta(t, 0.50, cost.ActualCost, 0.0001)
})
}
}
// TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍 // TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍
func TestCalculateImageCost_4KDoublePrice(t *testing.T) { func TestCalculateImageCost_4KDoublePrice(t *testing.T) {
svc := &BillingService{} svc := &BillingService{}

View File

@ -192,6 +192,46 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin
require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel)
} }
func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) {
imagePrice2K := 0.19
groupID := int64(901)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
Result: &ForwardResult{
RequestID: "gateway_image_default_size",
Model: "gemini-image",
ImageCount: 1,
ImageInputSize: "auto",
Duration: time.Second,
},
APIKey: &APIKey{
ID: 801,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
RateMultiplier: 1.0,
ImagePrice2K: &imagePrice2K,
},
},
User: &User{ID: 601},
Account: &Account{ID: 701},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, 1, usageRepo.lastLog.ImageCount)
require.NotNil(t, usageRepo.lastLog.ImageSize)
require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize)
require.NotNil(t, usageRepo.lastLog.ImageInputSize)
require.Equal(t, "auto", *usageRepo.lastLog.ImageInputSize)
require.NotNil(t, usageRepo.lastLog.ImageSizeSource)
require.Equal(t, ImageSizeSourceDefault, *usageRepo.lastLog.ImageSizeSource)
require.InDelta(t, 0.19, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.19, usageRepo.lastLog.ActualCost, 1e-12)
}
func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)}
userRepo := &openAIRecordUsageUserRepoStub{} userRepo := &openAIRecordUsageUserRepoStub{}

View File

@ -501,8 +501,13 @@ type ForwardResult struct {
ReasoningEffort *string ReasoningEffort *string
// 图片生成计费字段(图片生成模型使用) // 图片生成计费字段(图片生成模型使用)
ImageCount int // 生成的图片数量 ImageCount int // 生成的图片数量
ImageSize string // 图片尺寸 "1K", "2K", "4K" ImageSize string // 最终计费尺寸 "1K", "2K", "4K"
ImageInputSize string // 请求中的原始图片尺寸
ImageOutputSize string // 上游响应中的图片尺寸
ImageOutputSizes []string
ImageSizeSource string
ImageSizeBreakdown map[string]int
} }
// UpstreamFailoverError indicates an upstream error that should trigger account failover. // UpstreamFailoverError indicates an upstream error that should trigger account failover.
@ -8369,6 +8374,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
user := input.User user := input.User
account := input.Account account := input.Account
subscription := input.Subscription subscription := input.Subscription
ApplyForwardImageBillingResolution(result)
// 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens
// 用于粘性会话切换时的特殊计费处理 // 用于粘性会话切换时的特殊计费处理
@ -8514,6 +8520,7 @@ func (s *GatewayService) calculateImageCost(
billingModel string, billingModel string,
multiplier float64, multiplier float64,
) *CostBreakdown { ) *CostBreakdown {
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
tokens := UsageTokens{ tokens := UsageTokens{
InputTokens: result.Usage.InputTokens, InputTokens: result.Usage.InputTokens,
@ -8527,7 +8534,7 @@ func (s *GatewayService) calculateImageCost(
GroupID: &gid, GroupID: &gid,
Tokens: tokens, Tokens: tokens,
RequestCount: result.ImageCount, RequestCount: result.ImageCount,
SizeTier: result.ImageSize, SizeTier: sizeTier,
RateMultiplier: multiplier, RateMultiplier: multiplier,
Resolver: s.resolver, Resolver: s.resolver,
Resolved: resolved, Resolved: resolved,
@ -8547,7 +8554,7 @@ func (s *GatewayService) calculateImageCost(
Price4K: apiKey.Group.ImagePrice4K, Price4K: apiKey.Group.ImagePrice4K,
} }
} }
return s.billingService.CalculateImageCost(billingModel, result.ImageSize, result.ImageCount, groupConfig, multiplier) return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
} }
// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 // calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。
@ -8648,6 +8655,10 @@ func (s *GatewayService) buildRecordUsageLog(
FirstTokenMs: result.FirstTokenMs, FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount, ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize), ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
CacheTTLOverridden: cacheTTLOverridden, CacheTTLOverridden: cacheTTLOverridden,
ChannelID: optionalInt64Ptr(input.ChannelID), ChannelID: optionalInt64Ptr(input.ChannelID),
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),

View File

@ -1072,21 +1072,23 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
// 图片生成计费 // 图片生成计费
imageCount := 0 imageCount := 0
imageSize := s.extractImageSize(body) imageInputSize := s.extractImageInputSize(body)
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
if isImageGenerationModel(originalModel) { if isImageGenerationModel(originalModel) {
imageCount = 1 imageCount = 1
} }
return &ForwardResult{ return &ForwardResult{
RequestID: requestID, RequestID: requestID,
Usage: *usage, Usage: *usage,
Model: originalModel, Model: originalModel,
UpstreamModel: mappedModel, UpstreamModel: mappedModel,
Stream: req.Stream, Stream: req.Stream,
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount, ImageCount: imageCount,
ImageSize: imageSize, ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil }, nil
} }
@ -1600,21 +1602,23 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
// 图片生成计费 // 图片生成计费
imageCount := 0 imageCount := 0
imageSize := s.extractImageSize(body) imageInputSize := s.extractImageInputSize(body)
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
if isImageGenerationModel(originalModel) { if isImageGenerationModel(originalModel) {
imageCount = 1 imageCount = 1
} }
return &ForwardResult{ return &ForwardResult{
RequestID: requestID, RequestID: requestID,
Usage: *usage, Usage: *usage,
Model: originalModel, Model: originalModel,
UpstreamModel: mappedModel, UpstreamModel: mappedModel,
Stream: stream, Stream: stream,
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount, ImageCount: imageCount,
ImageSize: imageSize, ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil }, nil
} }
@ -3432,6 +3436,10 @@ func convertClaudeGenerationConfig(req map[string]any) map[string]any {
// extractImageSize 从 Gemini 请求中提取 image_size 参数 // extractImageSize 从 Gemini 请求中提取 image_size 参数
func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string { func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string {
return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body))
}
func (s *GeminiMessagesCompatService) extractImageInputSize(body []byte) string {
var req struct { var req struct {
GenerationConfig *struct { GenerationConfig *struct {
ImageConfig *struct { ImageConfig *struct {
@ -3440,15 +3448,12 @@ func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string {
} `json:"generationConfig"` } `json:"generationConfig"`
} }
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
return "2K" return ""
} }
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)
if size == "1K" || size == "2K" || size == "4K" {
return size
}
} }
return "2K" return ""
} }

View File

@ -0,0 +1,260 @@
package service
import (
"sort"
"strconv"
"strings"
)
const (
ImageBillingSize1K = "1K"
ImageBillingSize2K = "2K"
ImageBillingSize4K = "4K"
ImageSizeSourceOutput = "output"
ImageSizeSourceInput = "input"
ImageSizeSourceDefault = "default"
ImageSizeSourceLegacy = "legacy"
)
type ImageBillingSizeResolution struct {
BillingSize string
InputSize string
OutputSize string
Source string
Breakdown map[string]int
}
func ClassifyImageBillingTier(size string) (string, bool) {
trimmed := strings.TrimSpace(size)
normalized := strings.ToLower(trimmed)
switch normalized {
case "", "auto":
return "", false
case "1k":
return ImageBillingSize1K, true
case "2k":
return ImageBillingSize2K, true
case "4k":
return ImageBillingSize4K, true
case "2048x2048", "2048x1152":
return ImageBillingSize2K, true
case "3840x2160", "2160x3840":
return ImageBillingSize4K, true
}
width, height, ok := parseImageBillingDimensions(trimmed)
if !ok {
return "", false
}
maxEdge := width
if height > maxEdge {
maxEdge = height
}
switch {
case maxEdge <= 1024:
return ImageBillingSize1K, true
case maxEdge <= 2048:
return ImageBillingSize2K, true
default:
return ImageBillingSize4K, true
}
}
func NormalizeImageBillingTierOrDefault(size string) string {
if tier, ok := ClassifyImageBillingTier(size); ok {
return tier
}
return ImageBillingSize2K
}
func ResolveImageBillingSize(inputSize string, outputSizes []string) ImageBillingSizeResolution {
inputSize = strings.TrimSpace(inputSize)
outputSizes = compactTrimmedStrings(outputSizes)
breakdown := map[string]int{}
outputSize := firstDisplayImageOutputSize(outputSizes)
outputTier := ""
for _, output := range outputSizes {
tier, ok := ClassifyImageBillingTier(output)
if !ok {
continue
}
breakdown[tier]++
if imageTierRank(tier) > imageTierRank(outputTier) {
outputTier = tier
}
}
if outputTier != "" {
return ImageBillingSizeResolution{
BillingSize: outputTier,
InputSize: inputSize,
OutputSize: outputSize,
Source: ImageSizeSourceOutput,
Breakdown: normalizeImageSizeBreakdown(breakdown),
}
}
if tier, ok := ClassifyImageBillingTier(inputSize); ok {
return ImageBillingSizeResolution{
BillingSize: tier,
InputSize: inputSize,
OutputSize: outputSize,
Source: ImageSizeSourceInput,
}
}
return ImageBillingSizeResolution{
BillingSize: ImageBillingSize2K,
InputSize: inputSize,
OutputSize: outputSize,
Source: ImageSizeSourceDefault,
}
}
func ApplyOpenAIImageBillingResolution(result *OpenAIForwardResult) {
if result == nil || result.ImageCount <= 0 {
return
}
inputSize := strings.TrimSpace(result.ImageInputSize)
if inputSize == "" && strings.TrimSpace(result.ImageSize) != ImageBillingSize2K {
inputSize = strings.TrimSpace(result.ImageSize)
}
outputSizes := result.ImageOutputSizes
if len(outputSizes) == 0 && strings.TrimSpace(result.ImageOutputSize) != "" {
outputSizes = []string{result.ImageOutputSize}
}
resolved := ResolveImageBillingSize(inputSize, outputSizes)
applyImageBillingResolution(
&result.ImageSize,
&result.ImageInputSize,
&result.ImageOutputSize,
&result.ImageSizeSource,
&result.ImageSizeBreakdown,
resolved,
)
}
func ApplyForwardImageBillingResolution(result *ForwardResult) {
if result == nil || result.ImageCount <= 0 {
return
}
inputSize := strings.TrimSpace(result.ImageInputSize)
if inputSize == "" && strings.TrimSpace(result.ImageSize) != ImageBillingSize2K {
inputSize = strings.TrimSpace(result.ImageSize)
}
outputSizes := result.ImageOutputSizes
if len(outputSizes) == 0 && strings.TrimSpace(result.ImageOutputSize) != "" {
outputSizes = []string{result.ImageOutputSize}
}
resolved := ResolveImageBillingSize(inputSize, outputSizes)
applyImageBillingResolution(
&result.ImageSize,
&result.ImageInputSize,
&result.ImageOutputSize,
&result.ImageSizeSource,
&result.ImageSizeBreakdown,
resolved,
)
}
func applyImageBillingResolution(
billingSize *string,
inputSize *string,
outputSize *string,
source *string,
breakdown *map[string]int,
resolved ImageBillingSizeResolution,
) {
*billingSize = resolved.BillingSize
*inputSize = resolved.InputSize
*outputSize = resolved.OutputSize
*source = resolved.Source
*breakdown = resolved.Breakdown
}
func parseImageBillingDimensions(size string) (int, int, bool) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(size)), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func compactTrimmedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func firstDisplayImageOutputSize(outputSizes []string) string {
for _, output := range outputSizes {
if trimmed := strings.TrimSpace(output); trimmed != "" {
return trimmed
}
}
return ""
}
func imageTierRank(tier string) int {
switch strings.ToUpper(strings.TrimSpace(tier)) {
case ImageBillingSize1K:
return 1
case ImageBillingSize2K:
return 2
case ImageBillingSize4K:
return 3
default:
return 0
}
}
func normalizeImageSizeBreakdown(in map[string]int) map[string]int {
if len(in) == 0 {
return nil
}
out := make(map[string]int, len(in))
for _, tier := range []string{ImageBillingSize1K, ImageBillingSize2K, ImageBillingSize4K} {
if count := in[tier]; count > 0 {
out[tier] = count
}
}
if len(out) == 0 {
return nil
}
return out
}
func SortedImageBillingBreakdownKeys(breakdown map[string]int) []string {
keys := make([]string, 0, len(breakdown))
for key := range breakdown {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
left, right := imageTierRank(keys[i]), imageTierRank(keys[j])
if left == right {
return keys[i] < keys[j]
}
return left < right
})
return keys
}

View File

@ -0,0 +1,110 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestClassifyImageBillingTier(t *testing.T) {
tests := []struct {
name string
size string
wantTier string
wantOK bool
}{
{name: "explicit 2k square", size: "2048x2048", wantTier: "2K", wantOK: true},
{name: "explicit 2k landscape", size: "2048x1152", wantTier: "2K", wantOK: true},
{name: "explicit 4k landscape", size: "3840x2160", wantTier: "4K", wantOK: true},
{name: "explicit 4k portrait", size: "2160x3840", wantTier: "4K", wantOK: true},
{name: "long edge 1k", size: "1024X768", wantTier: "1K", wantOK: true},
{name: "long edge 2k", size: "1280x768", wantTier: "2K", wantOK: true},
{name: "long edge 4k", size: "2560x1600", wantTier: "4K", wantOK: true},
{name: "tier string 1k", size: "1k", wantTier: "1K", wantOK: true},
{name: "empty", size: "", wantOK: false},
{name: "auto", size: "auto", wantOK: false},
{name: "invalid", size: "not-a-size", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTier, gotOK := ClassifyImageBillingTier(tt.size)
require.Equal(t, tt.wantOK, gotOK)
require.Equal(t, tt.wantTier, gotTier)
})
}
}
func TestResolveImageBillingSize(t *testing.T) {
tests := []struct {
name string
inputSize string
outputSizes []string
wantBilling string
wantOutput string
wantSource string
wantBreakdown map[string]int
}{
{
name: "output wins over input",
inputSize: "1024x1024",
outputSizes: []string{"3840x2160"},
wantBilling: "4K",
wantOutput: "3840x2160",
wantSource: ImageSizeSourceOutput,
wantBreakdown: map[string]int{"4K": 1},
},
{
name: "input fallback",
inputSize: "1024x1024",
wantBilling: "1K",
wantSource: ImageSizeSourceInput,
},
{
name: "auto defaults",
inputSize: "auto",
wantBilling: "2K",
wantSource: ImageSizeSourceDefault,
},
{
name: "empty defaults",
inputSize: "",
wantBilling: "2K",
wantSource: ImageSizeSourceDefault,
},
{
name: "invalid defaults",
inputSize: "largest",
wantBilling: "2K",
wantSource: ImageSizeSourceDefault,
},
{
name: "mixed output chooses highest tier",
inputSize: "1024x1024",
outputSizes: []string{"1024x1024", "3840x2160", "1280x720"},
wantBilling: "4K",
wantOutput: "1024x1024",
wantSource: ImageSizeSourceOutput,
wantBreakdown: map[string]int{"1K": 1, "2K": 1, "4K": 1},
},
{
name: "unparseable output falls back to parseable input",
inputSize: "2048x1152",
outputSizes: []string{"auto"},
wantBilling: "2K",
wantOutput: "auto",
wantSource: ImageSizeSourceInput,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ResolveImageBillingSize(tt.inputSize, tt.outputSizes)
require.Equal(t, tt.wantBilling, got.BillingSize)
require.Equal(t, tt.inputSize, got.InputSize)
require.Equal(t, tt.wantOutput, got.OutputSize)
require.Equal(t, tt.wantSource, got.Source)
require.Equal(t, tt.wantBreakdown, got.Breakdown)
})
}
}

View File

@ -170,7 +170,21 @@ func cloneRequestMapForImageIntent(body []byte) map[string]any {
return out return out
} }
type OpenAIResponsesImageBillingConfig struct {
Model string
SizeTier string
InputSize string
}
func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) { func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) {
cfg, err := resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, fallbackModel)
if err != nil {
return "", "", err
}
return cfg.Model, cfg.SizeTier, nil
}
func resolveOpenAIResponsesImageBillingConfigDetailed(reqBody map[string]any, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) {
imageModel := "" imageModel := ""
imageSize := "" imageSize := ""
hasImageTool := false hasImageTool := false
@ -203,12 +217,24 @@ func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackMo
imageModel = strings.TrimSpace(fallbackModel) imageModel = strings.TrimSpace(fallbackModel)
} }
sizeTier := normalizeOpenAIImageSizeTier(imageSize) sizeTier := normalizeOpenAIImageSizeTier(imageSize)
return imageModel, sizeTier, nil return OpenAIResponsesImageBillingConfig{
Model: imageModel,
SizeTier: sizeTier,
InputSize: imageSize,
}, nil
} }
func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) { func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) {
cfg, err := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, fallbackModel)
if err != nil {
return "", "", err
}
return cfg.Model, cfg.SizeTier, nil
}
func resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body []byte, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) {
reqBody := cloneRequestMapForImageIntent(body) reqBody := cloneRequestMapForImageIntent(body)
return resolveOpenAIResponsesImageBillingConfig(reqBody, fallbackModel) return resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, fallbackModel)
} }
func isOpenAIImageBillingModelAlias(model string) bool { func isOpenAIImageBillingModelAlias(model string) bool {

View File

@ -140,9 +140,10 @@ func TestResolveOpenAIResponsesImageBillingConfigDoesNotRejectUnknownSizes(t *te
func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) { func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) {
counter := newOpenAIImageOutputCounter() counter := newOpenAIImageOutputCounter()
counter.AddSSEData([]byte(`{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}`)) counter.AddSSEData([]byte(`{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}`))
counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a"}}`)) counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a","size":"1024x1024"}}`))
counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b"}]}}`)) counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b","size":"3840x2160"}]}}`))
require.Equal(t, 2, counter.Count()) require.Equal(t, 2, counter.Count())
require.Equal(t, []string{"1024x1024", "3840x2160"}, counter.Sizes())
} }
func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) { func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) {
@ -182,3 +183,36 @@ func TestOpenAIImageOutputCounterFallsBackForInvalidMultilineSSEBody(t *testing.
) )
require.Equal(t, 2, counter.Count()) require.Equal(t, 2, counter.Count())
} }
func TestCollectOpenAIResponseImageOutputSizesFromJSONBytes(t *testing.T) {
body := []byte(`{
"output": [
{"id":"ig_1","type":"image_generation_call","result":"final-a","size":"3840x2160"},
{"id":"ig_2","type":"image_generation_call","result":"final-b","size":"1024x1024"}
]
}`)
require.Equal(t, 2, countOpenAIResponseImageOutputsFromJSONBytes(body))
require.Equal(t, []string{"3840x2160", "1024x1024"}, collectOpenAIResponseImageOutputSizesFromJSONBytes(body))
}
func TestCollectOpenAIResponseImageOutputSizesFromImagesAPIData(t *testing.T) {
body := []byte(`{
"data": [
{"b64_json":"final-a","size":"2048x1152"},
{"b64_json":"final-b","size":"2048x1152"}
]
}`)
require.Equal(t, 2, countOpenAIResponseImageOutputsFromJSONBytes(body))
require.Equal(t, []string{"2048x1152", "2048x1152"}, collectOpenAIResponseImageOutputSizesFromJSONBytes(body))
}
func TestCollectOpenAIImageOutputSizesFromSSEBody(t *testing.T) {
body := "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_1\",\"type\":\"image_generation_call\",\"result\":\"final-a\",\"size\":\"3840x2160\"}}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"id\":\"ig_1\",\"type\":\"image_generation_call\",\"result\":\"final-a\"},{\"id\":\"ig_2\",\"type\":\"image_generation_call\",\"result\":\"final-b\",\"size\":\"1024x1024\"}]}}\n\n" +
"data: [DONE]\n\n"
require.Equal(t, 2, countOpenAIImageOutputsFromSSEBody(body))
require.Equal(t, []string{"3840x2160", "1024x1024"}, collectOpenAIImageOutputSizesFromSSEBody(body))
}

View File

@ -10,12 +10,18 @@ import (
type openAIImageOutputCounter struct { type openAIImageOutputCounter struct {
seen map[string]struct{} seen map[string]struct{}
seenSizes map[string]string
seenOrder []string
dataSizes []string
count int count int
maxDataCount int maxDataCount int
} }
func newOpenAIImageOutputCounter() *openAIImageOutputCounter { func newOpenAIImageOutputCounter() *openAIImageOutputCounter {
return &openAIImageOutputCounter{seen: make(map[string]struct{})} return &openAIImageOutputCounter{
seen: make(map[string]struct{}),
seenSizes: make(map[string]string),
}
} }
func (c *openAIImageOutputCounter) Count() int { func (c *openAIImageOutputCounter) Count() int {
@ -28,6 +34,25 @@ func (c *openAIImageOutputCounter) Count() int {
return c.count return c.count
} }
func (c *openAIImageOutputCounter) Sizes() []string {
if c == nil {
return nil
}
sizes := make([]string, 0, len(c.seenOrder)+len(c.dataSizes))
for _, key := range c.seenOrder {
if size := strings.TrimSpace(c.seenSizes[key]); size != "" {
sizes = append(sizes, size)
}
}
if len(sizes) == 0 && len(c.dataSizes) > 0 {
sizes = append(sizes, c.dataSizes...)
}
if len(sizes) == 0 {
return nil
}
return sizes
}
func (c *openAIImageOutputCounter) AddJSONResponse(body []byte) { func (c *openAIImageOutputCounter) AddJSONResponse(body []byte) {
if c == nil || len(body) == 0 || !gjson.ValidBytes(body) { if c == nil || len(body) == 0 || !gjson.ValidBytes(body) {
return return
@ -73,10 +98,20 @@ func (c *openAIImageOutputCounter) addDataArray(data gjson.Result) {
if !data.IsArray() { if !data.IsArray() {
return return
} }
count := len(data.Array()) items := data.Array()
count := len(items)
if count > c.maxDataCount { if count > c.maxDataCount {
c.maxDataCount = count c.maxDataCount = count
} }
sizes := make([]string, 0, len(items))
for _, item := range items {
if size := strings.TrimSpace(item.Get("size").String()); size != "" {
sizes = append(sizes, size)
}
}
if len(sizes) > 0 {
c.dataSizes = sizes
}
} }
func (c *openAIImageOutputCounter) addOutputArray(output gjson.Result) { func (c *openAIImageOutputCounter) addOutputArray(output gjson.Result) {
@ -120,10 +155,18 @@ func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) {
if key == "" { if key == "" {
return return
} }
size := strings.TrimSpace(item.Get("size").String())
if _, exists := c.seen[key]; exists { if _, exists := c.seen[key]; exists {
if size != "" && strings.TrimSpace(c.seenSizes[key]) == "" {
c.seenSizes[key] = size
}
return return
} }
c.seen[key] = struct{}{} c.seen[key] = struct{}{}
c.seenOrder = append(c.seenOrder, key)
if size != "" {
c.seenSizes[key] = size
}
c.count++ c.count++
} }
@ -142,8 +185,20 @@ func countOpenAIResponseImageOutputsFromJSONBytes(body []byte) int {
return counter.Count() return counter.Count()
} }
func collectOpenAIResponseImageOutputSizesFromJSONBytes(body []byte) []string {
counter := newOpenAIImageOutputCounter()
counter.AddJSONResponse(body)
return counter.Sizes()
}
func countOpenAIImageOutputsFromSSEBody(body string) int { func countOpenAIImageOutputsFromSSEBody(body string) int {
counter := newOpenAIImageOutputCounter() counter := newOpenAIImageOutputCounter()
counter.AddSSEBody(body) counter.AddSSEBody(body)
return counter.Count() return counter.Count()
} }
func collectOpenAIImageOutputSizesFromSSEBody(body string) []string {
counter := newOpenAIImageOutputCounter()
counter.AddSSEBody(body)
return counter.Sizes()
}

View File

@ -1320,6 +1320,93 @@ func TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists(t *testing.
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
} }
func TestOpenAIGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) {
imagePrice2K := 0.31
groupID := int64(1201)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_image_default_size",
Model: "gpt-image-2",
ImageCount: 2,
ImageSize: "",
Duration: time.Second,
},
APIKey: &APIKey{
ID: 11201,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
RateMultiplier: 1.0,
ImagePrice2K: &imagePrice2K,
},
},
User: &User{ID: 21201},
Account: &Account{ID: 31201},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, 2, usageRepo.lastLog.ImageCount)
require.NotNil(t, usageRepo.lastLog.ImageSize)
require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize)
require.NotNil(t, usageRepo.lastLog.ImageSizeSource)
require.Equal(t, ImageSizeSourceDefault, *usageRepo.lastLog.ImageSizeSource)
require.Nil(t, usageRepo.lastLog.ImageInputSize)
require.Nil(t, usageRepo.lastLog.ImageOutputSize)
require.InDelta(t, 0.62, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.62, usageRepo.lastLog.ActualCost, 1e-12)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
}
func TestOpenAIGatewayServiceRecordUsage_OutputImageSizeWinsBeforeBillingAndPersistence(t *testing.T) {
imagePrice1K := 0.11
imagePrice4K := 0.44
groupID := int64(1202)
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_image_output_size",
Model: "gpt-image-2",
ImageCount: 1,
ImageInputSize: "1024x1024",
ImageOutputSizes: []string{"3840x2160"},
Duration: time.Second,
},
APIKey: &APIKey{
ID: 11202,
GroupID: i64p(groupID),
Group: &Group{
ID: groupID,
RateMultiplier: 1.0,
ImagePrice1K: &imagePrice1K,
ImagePrice4K: &imagePrice4K,
},
},
User: &User{ID: 21202},
Account: &Account{ID: 31202},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.NotNil(t, usageRepo.lastLog.ImageSize)
require.Equal(t, ImageBillingSize4K, *usageRepo.lastLog.ImageSize)
require.NotNil(t, usageRepo.lastLog.ImageInputSize)
require.Equal(t, "1024x1024", *usageRepo.lastLog.ImageInputSize)
require.NotNil(t, usageRepo.lastLog.ImageOutputSize)
require.Equal(t, "3840x2160", *usageRepo.lastLog.ImageOutputSize)
require.NotNil(t, usageRepo.lastLog.ImageSizeSource)
require.Equal(t, ImageSizeSourceOutput, *usageRepo.lastLog.ImageSizeSource)
require.Equal(t, map[string]int{ImageBillingSize4K: 1}, usageRepo.lastLog.ImageSizeBreakdown)
require.InDelta(t, 0.44, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, 0.44, usageRepo.lastLog.ActualCost, 1e-12)
}
func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTokens(t *testing.T) { func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTokens(t *testing.T) {
imagePrice := 0.02 imagePrice := 0.02
groupID := int64(12) groupID := int64(12)
@ -1641,3 +1728,42 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(
require.InDelta(t, 0.80, cost.TotalCost, 1e-12) require.InDelta(t, 0.80, cost.TotalCost, 1e-12)
require.InDelta(t, 0.80, cost.ActualCost, 1e-12) require.InDelta(t, 0.80, cost.ActualCost, 1e-12)
} }
func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMissingSizeTier(t *testing.T) {
groupID := int64(128)
defaultPrice := 0.10
price2K := 0.22
cache := newEmptyChannelCache()
cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: "gemini-image"}] = &ChannelModelPricing{
BillingMode: BillingModeImage,
PerRequestPrice: &defaultPrice,
Intervals: []PricingInterval{{
TierLabel: "2K",
PerRequestPrice: &price2K,
}},
}
cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive}
cache.loadedAt = time.Now()
channelService := &ChannelService{}
channelService.cache.Store(cache)
svc := &GatewayService{
billingService: NewBillingService(&config.Config{}, nil),
resolver: NewModelPricingResolver(channelService, NewBillingService(&config.Config{}, nil)),
}
cost := svc.calculateRecordUsageCost(
context.Background(),
&ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: ""},
&APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}},
"gemini-image",
1.0,
1.0,
nil,
)
require.NotNil(t, cost)
require.Equal(t, string(BillingModeImage), cost.BillingMode)
require.InDelta(t, 0.44, cost.TotalCost, 1e-12)
require.InDelta(t, 0.44, cost.ActualCost, 1e-12)
}

View File

@ -228,14 +228,19 @@ type OpenAIForwardResult struct {
ServiceTier *string ServiceTier *string
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix.
// Stored for usage records display; nil means not provided / not applicable. // Stored for usage records display; nil means not provided / not applicable.
ReasoningEffort *string ReasoningEffort *string
Stream bool Stream bool
OpenAIWSMode bool OpenAIWSMode bool
ResponseHeaders http.Header ResponseHeaders http.Header
Duration time.Duration Duration time.Duration
FirstTokenMs *int FirstTokenMs *int
ImageCount int ImageCount int
ImageSize string ImageSize string
ImageInputSize string
ImageOutputSize string
ImageOutputSizes []string
ImageSizeSource string
ImageSizeBreakdown map[string]int
} }
type OpenAIWSRetryMetricsSnapshot struct { type OpenAIWSRetryMetricsSnapshot struct {
@ -2416,9 +2421,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
imageBillingModel := "" imageBillingModel := ""
imageSizeTier := "" imageSizeTier := ""
imageInputSize := ""
if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) { if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) {
var imageCfgErr error var imageCfgErr error
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfig(reqBody, billingModel) imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
if imageCfgErr != nil { if imageCfgErr != nil {
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@ -2430,6 +2436,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}) })
return nil, imageCfgErr return nil, imageCfgErr
} }
imageBillingModel = imageCfg.Model
imageSizeTier = imageCfg.SizeTier
imageInputSize = imageCfg.InputSize
} }
// Re-serialize body only if modified // Re-serialize body only if modified
@ -2671,6 +2680,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
wsResult.UpstreamModel = upstreamModel wsResult.UpstreamModel = upstreamModel
if wsResult.ImageCount > 0 { if wsResult.ImageCount > 0 {
wsResult.ImageSize = imageSizeTier wsResult.ImageSize = imageSizeTier
wsResult.ImageInputSize = imageInputSize
wsResult.BillingModel = imageBillingModel wsResult.BillingModel = imageBillingModel
} }
return wsResult, nil return wsResult, nil
@ -2777,6 +2787,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
var usage *OpenAIUsage var usage *OpenAIUsage
var firstTokenMs *int var firstTokenMs *int
imageCount := 0 imageCount := 0
var imageOutputSizes []string
if reqStream { if reqStream {
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
if err != nil { if err != nil {
@ -2785,6 +2796,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
usage = streamResult.usage usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs firstTokenMs = streamResult.firstTokenMs
imageCount = streamResult.imageCount imageCount = streamResult.imageCount
imageOutputSizes = streamResult.imageOutputSizes
} else { } else {
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
if err != nil { if err != nil {
@ -2792,6 +2804,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
usage = nonStreamResult.usage usage = nonStreamResult.usage
imageCount = nonStreamResult.imageCount imageCount = nonStreamResult.imageCount
imageOutputSizes = nonStreamResult.imageOutputSizes
} }
// Extract and save Codex usage snapshot from response headers (for OAuth accounts) // Extract and save Codex usage snapshot from response headers (for OAuth accounts)
@ -2823,6 +2836,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if imageCount > 0 { if imageCount > 0 {
forwardResult.ImageCount = imageCount forwardResult.ImageCount = imageCount
forwardResult.ImageSize = imageSizeTier forwardResult.ImageSize = imageSizeTier
forwardResult.ImageInputSize = imageInputSize
forwardResult.ImageOutputSizes = imageOutputSizes
forwardResult.BillingModel = imageBillingModel forwardResult.BillingModel = imageBillingModel
} }
return forwardResult, nil return forwardResult, nil
@ -2927,9 +2942,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
} }
imageBillingModel := "" imageBillingModel := ""
imageSizeTier := "" imageSizeTier := ""
imageInputSize := ""
if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) { if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) {
var imageCfgErr error var imageCfgErr error
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(body, reqModel) imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
if imageCfgErr != nil { if imageCfgErr != nil {
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@ -2941,6 +2957,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}) })
return nil, imageCfgErr return nil, imageCfgErr
} }
imageBillingModel = imageCfg.Model
imageSizeTier = imageCfg.SizeTier
imageInputSize = imageCfg.InputSize
} }
logger.LegacyPrintf("service.openai_gateway", logger.LegacyPrintf("service.openai_gateway",
@ -3026,6 +3045,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
var usage *OpenAIUsage var usage *OpenAIUsage
var firstTokenMs *int var firstTokenMs *int
imageCount := 0 imageCount := 0
var imageOutputSizes []string
if reqStream { if reqStream {
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
if err != nil { if err != nil {
@ -3034,6 +3054,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
usage = result.usage usage = result.usage
firstTokenMs = result.firstTokenMs firstTokenMs = result.firstTokenMs
imageCount = result.imageCount imageCount = result.imageCount
imageOutputSizes = result.imageOutputSizes
} else { } else {
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
if err != nil { if err != nil {
@ -3041,6 +3062,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
} }
usage = result.usage usage = result.usage
imageCount = result.imageCount imageCount = result.imageCount
imageOutputSizes = result.imageOutputSizes
} }
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
@ -3066,6 +3088,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
if imageCount > 0 { if imageCount > 0 {
forwardResult.ImageCount = imageCount forwardResult.ImageCount = imageCount
forwardResult.ImageSize = imageSizeTier forwardResult.ImageSize = imageSizeTier
forwardResult.ImageInputSize = imageInputSize
forwardResult.ImageOutputSizes = imageOutputSizes
forwardResult.BillingModel = imageBillingModel forwardResult.BillingModel = imageBillingModel
} }
return forwardResult, nil return forwardResult, nil
@ -3361,15 +3385,17 @@ func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
} }
type openaiStreamingResultPassthrough struct { type openaiStreamingResultPassthrough struct {
usage *OpenAIUsage usage *OpenAIUsage
firstTokenMs *int firstTokenMs *int
imageCount int imageCount int
imageOutputSizes []string
} }
type openaiNonStreamingResultPassthrough struct { type openaiNonStreamingResultPassthrough struct {
*OpenAIUsage *OpenAIUsage
usage *OpenAIUsage usage *OpenAIUsage
imageCount int imageCount int
imageOutputSizes []string
} }
func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
@ -3539,7 +3565,12 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel) needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel)
resultWithUsage := func() *openaiStreamingResultPassthrough { resultWithUsage := func() *openaiStreamingResultPassthrough {
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} return &openaiStreamingResultPassthrough{
usage: usage,
firstTokenMs: firstTokenMs,
imageCount: imageCounter.Count(),
imageOutputSizes: imageCounter.Sizes(),
}
} }
for scanner.Scan() { for scanner.Scan() {
@ -3696,9 +3727,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
} }
c.Data(resp.StatusCode, contentType, body) c.Data(resp.StatusCode, contentType, body)
return &openaiNonStreamingResultPassthrough{ return &openaiNonStreamingResultPassthrough{
OpenAIUsage: usage, OpenAIUsage: usage,
usage: usage, usage: usage,
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
}, nil }, nil
} }
@ -3758,9 +3790,10 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
c.Data(resp.StatusCode, contentType, body) c.Data(resp.StatusCode, contentType, body)
return &openaiNonStreamingResultPassthrough{ return &openaiNonStreamingResultPassthrough{
OpenAIUsage: usage, OpenAIUsage: usage,
usage: usage, usage: usage,
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
}, nil }, nil
} }
@ -4182,15 +4215,17 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
// openaiStreamingResult streaming response result // openaiStreamingResult streaming response result
type openaiStreamingResult struct { type openaiStreamingResult struct {
usage *OpenAIUsage usage *OpenAIUsage
firstTokenMs *int firstTokenMs *int
imageCount int imageCount int
imageOutputSizes []string
} }
type openaiNonStreamingResult struct { type openaiNonStreamingResult struct {
*OpenAIUsage *OpenAIUsage
usage *OpenAIUsage usage *OpenAIUsage
imageCount int imageCount int
imageOutputSizes []string
} }
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
@ -4303,7 +4338,12 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
needModelReplace := originalModel != mappedModel needModelReplace := originalModel != mappedModel
resultWithUsage := func() *openaiStreamingResult { resultWithUsage := func() *openaiStreamingResult {
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} return &openaiStreamingResult{
usage: usage,
firstTokenMs: firstTokenMs,
imageCount: imageCounter.Count(),
imageOutputSizes: imageCounter.Sizes(),
}
} }
finalizeStream := func() (*openaiStreamingResult, error) { finalizeStream := func() (*openaiStreamingResult, error) {
if !sawTerminalEvent { if !sawTerminalEvent {
@ -4711,9 +4751,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
c.Data(resp.StatusCode, contentType, body) c.Data(resp.StatusCode, contentType, body)
return &openaiNonStreamingResult{ return &openaiNonStreamingResult{
OpenAIUsage: usage, OpenAIUsage: usage,
usage: usage, usage: usage,
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
}, nil }, nil
} }
@ -4775,9 +4816,10 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
c.Data(resp.StatusCode, contentType, body) c.Data(resp.StatusCode, contentType, body)
return &openaiNonStreamingResult{ return &openaiNonStreamingResult{
OpenAIUsage: usage, OpenAIUsage: usage,
usage: usage, usage: usage,
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
}, nil }, nil
} }
@ -5216,6 +5258,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
user := input.User user := input.User
account := input.Account account := input.Account
subscription := input.Subscription subscription := input.Subscription
ApplyOpenAIImageBillingResolution(result)
// 计算实际的新输入token减去缓存读取的token // 计算实际的新输入token减去缓存读取的token
// 因为 input_tokens 包含了 cache_read_tokens而缓存读取的token不应按输入价格计费 // 因为 input_tokens 包含了 cache_read_tokens而缓存读取的token不应按输入价格计费
@ -5325,6 +5368,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
ImageOutputTokens: result.Usage.ImageOutputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens,
ImageCount: result.ImageCount, ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize), ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
} }
if cost != nil { if cost != nil {
usageLog.InputCost = cost.InputCost usageLog.InputCost = cost.InputCost
@ -5493,6 +5540,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
result *OpenAIForwardResult, result *OpenAIForwardResult,
multiplier float64, multiplier float64,
) *CostBreakdown { ) *CostBreakdown {
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
gid := apiKey.Group.ID gid := apiKey.Group.ID
@ -5501,7 +5549,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
Model: billingModel, Model: billingModel,
GroupID: &gid, GroupID: &gid,
RequestCount: result.ImageCount, RequestCount: result.ImageCount,
SizeTier: result.ImageSize, SizeTier: sizeTier,
RateMultiplier: multiplier, RateMultiplier: multiplier,
Resolver: s.resolver, Resolver: s.resolver,
Resolved: resolved, Resolved: resolved,
@ -5520,7 +5568,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
Price4K: apiKey.Group.ImagePrice4K, Price4K: apiKey.Group.ImagePrice4K,
} }
} }
return s.billingService.CalculateImageCost(billingModel, result.ImageSize, result.ImageCount, groupConfig, multiplier) return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
} }
func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {

View File

@ -532,54 +532,7 @@ func isOpenAINativeImageOption(name string) bool {
} }
func normalizeOpenAIImageSizeTier(size string) string { func normalizeOpenAIImageSizeTier(size string) string {
trimmed := strings.TrimSpace(size) return NormalizeImageBillingTierOrDefault(size)
normalized := strings.ToLower(trimmed)
switch normalized {
case "", "auto":
return "2K"
case "1024x1024":
return "1K"
case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "2048x2048", "2048x1152", "1152x2048":
return "2K"
case "3840x2160", "2160x3840":
return "4K"
}
width, height, ok := parseOpenAIImageSizeDimensions(trimmed)
if !ok {
return "2K"
}
return classifyUnknownOpenAIImageSizeTier(width, height)
}
const (
openAIImage2KMaxPixels = 2560 * 1440
)
func parseOpenAIImageSizeDimensions(size string) (int, int, bool) {
trimmed := strings.TrimSpace(size)
parts := strings.Split(strings.ToLower(trimmed), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func classifyUnknownOpenAIImageSizeTier(width int, height int) string {
if height > 0 && width > openAIImage2KMaxPixels/height {
return "4K"
}
return "2K"
} }
func (s *OpenAIGatewayService) ForwardImages( func (s *OpenAIGatewayService) ForwardImages(
@ -704,29 +657,46 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
imageCount := parsed.N imageCount := parsed.N
var firstTokenMs *int var firstTokenMs *int
if parsed.Stream && isEventStreamResponse(resp.Header) { if parsed.Stream && isEventStreamResponse(resp.Header) {
streamUsage, streamCount, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime) streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime)
if err != nil { if err != nil {
if streamCount > 0 { if streamCount > 0 {
return &OpenAIForwardResult{ return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"), RequestID: resp.Header.Get("x-request-id"),
Usage: streamUsage, Usage: streamUsage,
Model: requestModel, Model: requestModel,
UpstreamModel: upstreamModel, UpstreamModel: upstreamModel,
Stream: parsed.Stream, Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(), ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: ttft, FirstTokenMs: ttft,
ImageCount: streamCount, ImageCount: streamCount,
ImageSize: parsed.SizeTier, ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: streamSizes,
}, err }, err
} }
return nil, err return nil, err
} }
usage = streamUsage usage = streamUsage
imageCount = streamCount imageCount = streamCount
imageOutputSizes := streamSizes
firstTokenMs = ttft firstTokenMs = ttft
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: imageOutputSizes,
}, nil
} else { } else {
nonStreamUsage, nonStreamCount, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -734,19 +704,21 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
if nonStreamCount > 0 { if nonStreamCount > 0 {
imageCount = nonStreamCount imageCount = nonStreamCount
} }
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: nonStreamSizes,
}, nil
} }
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
}, nil
} }
func (s *OpenAIGatewayService) buildOpenAIImagesRequest( func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
@ -892,10 +864,10 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
return dst return dst
} }
func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, error) { func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil { if err != nil {
return OpenAIUsage{}, 0, err return OpenAIUsage{}, 0, nil, err
} }
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json" contentType := "application/json"
@ -907,14 +879,14 @@ func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http
c.Data(resp.StatusCode, contentType, body) c.Data(resp.StatusCode, contentType, body)
usage, _ := extractOpenAIUsageFromJSONBytes(body) usage, _ := extractOpenAIUsageFromJSONBytes(body)
return usage, extractOpenAIImageCountFromJSONBytes(body), nil return usage, extractOpenAIImageCountFromJSONBytes(body), collectOpenAIResponseImageOutputSizesFromJSONBytes(body), nil
} }
func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
resp *http.Response, resp *http.Response,
c *gin.Context, c *gin.Context,
startTime time.Time, startTime time.Time,
) (OpenAIUsage, int, *int, error) { ) (OpenAIUsage, int, []string, *int, error) {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" { if contentType == "" {
@ -925,7 +897,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
flusher, ok := c.Writer.(http.Flusher) flusher, ok := c.Writer.(http.Flusher)
if !ok { if !ok {
return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer") return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer")
} }
usage := OpenAIUsage{} usage := OpenAIUsage{}
@ -1010,12 +982,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
} }
if err != nil { if err != nil {
flushSSEEvent() flushSSEEvent()
return usage, imageCounter.Count(), firstTokenMs, err return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, err
} }
} }
flushSSEEvent() flushSSEEvent()
finalizeFallbackBody() finalizeFallbackBody()
return usage, imageCounter.Count(), firstTokenMs, nil return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
} }
type readEvent struct { type readEvent struct {
@ -1082,11 +1054,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
if !ok { if !ok {
flushSSEEvent() flushSSEEvent()
finalizeFallbackBody() finalizeFallbackBody()
return usage, imageCounter.Count(), firstTokenMs, nil return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
} }
if ev.err != nil { if ev.err != nil {
flushSSEEvent() flushSSEEvent()
return usage, imageCounter.Count(), firstTokenMs, ev.err return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, ev.err
} }
processLine(ev.line) processLine(ev.line)
case <-intervalCh: case <-intervalCh:
@ -1095,11 +1067,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
continue continue
} }
if clientDisconnected { if clientDisconnected {
return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout") return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
} }
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval) logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval)
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream data interval timeout") return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream data interval timeout")
case <-keepaliveCh: case <-keepaliveCh:
if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
continue continue

View File

@ -72,6 +72,22 @@ func mergeOpenAIResponsesImageMeta(dst *openAIResponsesImageResult, src openAIRe
} }
} }
func openAIResponsesImageResultSizes(results []openAIResponsesImageResult) []string {
if len(results) == 0 {
return nil
}
sizes := make([]string, 0, len(results))
for _, result := range results {
if size := strings.TrimSpace(result.Size); size != "" {
sizes = append(sizes, size)
}
}
if len(sizes) == 0 {
return nil
}
return sizes
}
func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) { func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) {
switch gjson.GetBytes(payload, "type").String() { switch gjson.GetBytes(payload, "type").String() {
case "response.created", "response.in_progress", "response.completed": case "response.created", "response.in_progress", "response.completed":
@ -547,10 +563,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
c *gin.Context, c *gin.Context,
responseFormat string, responseFormat string,
fallbackModel string, fallbackModel string,
) (OpenAIUsage, int, error) { ) (OpenAIUsage, int, []string, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil { if err != nil {
return OpenAIUsage{}, 0, err return OpenAIUsage{}, 0, nil, err
} }
var usage OpenAIUsage var usage OpenAIUsage
@ -559,10 +575,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
}) })
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body) results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
if err != nil { if err != nil {
return OpenAIUsage{}, 0, err return OpenAIUsage{}, 0, nil, err
} }
if len(results) == 0 { if len(results) == 0 {
return OpenAIUsage{}, 0, fmt.Errorf("upstream did not return image output") return OpenAIUsage{}, 0, nil, fmt.Errorf("upstream did not return image output")
} }
if strings.TrimSpace(firstMeta.Model) == "" { if strings.TrimSpace(firstMeta.Model) == "" {
firstMeta.Model = strings.TrimSpace(fallbackModel) firstMeta.Model = strings.TrimSpace(fallbackModel)
@ -570,11 +586,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat) responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat)
if err != nil { if err != nil {
return OpenAIUsage{}, 0, err return OpenAIUsage{}, 0, nil, err
} }
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody) c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody)
return usage, len(results), nil return usage, len(results), openAIResponsesImageResultSizes(results), nil
} }
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
@ -584,7 +600,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
responseFormat string, responseFormat string,
streamPrefix string, streamPrefix string,
fallbackModel string, fallbackModel string,
) (OpenAIUsage, int, *int, error) { ) (OpenAIUsage, int, []string, *int, error) {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@ -593,7 +609,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
flusher, ok := c.Writer.(http.Flusher) flusher, ok := c.Writer.(http.Flusher)
if !ok { if !ok {
return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer") return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer")
} }
format := strings.ToLower(strings.TrimSpace(responseFormat)) format := strings.ToLower(strings.TrimSpace(responseFormat))
@ -603,6 +619,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
usage := OpenAIUsage{} usage := OpenAIUsage{}
imageCount := 0 imageCount := 0
var imageOutputSizes []string
var firstTokenMs *int var firstTokenMs *int
emitted := make(map[string]struct{}) emitted := make(map[string]struct{})
pendingResults := make([]openAIResponsesImageResult, 0, 1) pendingResults := make([]openAIResponsesImageResult, 0, 1)
@ -713,6 +730,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
} }
imageCount = len(emitted) imageCount = len(emitted)
imageOutputSizes = openAIResponsesImageResultSizes(finalResults)
processDataDone = true processDataDone = true
} }
} }
@ -753,6 +771,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
} }
imageCount = len(emitted) imageCount = len(emitted)
imageOutputSizes = openAIResponsesImageResultSizes(pendingResults)
return nil return nil
} }
@ -769,33 +788,33 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
line, err := reader.ReadBytes('\n') line, err := reader.ReadBytes('\n')
done, processErr := processLine(line) done, processErr := processLine(line)
if processErr != nil { if processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} }
if done { if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
if err == io.EOF { if err == io.EOF {
break break
} }
if err != nil { if err != nil {
if done, processErr := flushData(); processErr != nil { if done, processErr := flushData(); processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} else if done { } else if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(err.Error())) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
return usage, imageCount, firstTokenMs, err return usage, imageCount, imageOutputSizes, firstTokenMs, err
} }
} }
if done, processErr := flushData(); processErr != nil { if done, processErr := flushData(); processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} else if done { } else if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
if err := finalizePending(); err != nil { if err := finalizePending(); err != nil {
return usage, imageCount, firstTokenMs, err return usage, imageCount, imageOutputSizes, firstTokenMs, err
} }
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
type readEvent struct { type readEvent struct {
@ -861,30 +880,30 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
case ev, ok := <-events: case ev, ok := <-events:
if !ok { if !ok {
if done, processErr := flushData(); processErr != nil { if done, processErr := flushData(); processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} else if done { } else if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
if err := finalizePending(); err != nil { if err := finalizePending(); err != nil {
return usage, imageCount, firstTokenMs, err return usage, imageCount, imageOutputSizes, firstTokenMs, err
} }
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
if ev.err != nil { if ev.err != nil {
if done, processErr := flushData(); processErr != nil { if done, processErr := flushData(); processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} else if done { } else if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(ev.err.Error())) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(ev.err.Error()))
return usage, imageCount, firstTokenMs, ev.err return usage, imageCount, imageOutputSizes, firstTokenMs, ev.err
} }
done, processErr := processLine(ev.line) done, processErr := processLine(ev.line)
if processErr != nil { if processErr != nil {
return usage, imageCount, firstTokenMs, processErr return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
} }
if done { if done {
return usage, imageCount, firstTokenMs, nil return usage, imageCount, imageOutputSizes, firstTokenMs, nil
} }
case <-intervalCh: case <-intervalCh:
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
@ -892,11 +911,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
continue continue
} }
if clientDisconnected { if clientDisconnected {
return usage, imageCount, firstTokenMs, fmt.Errorf("image stream incomplete after timeout") return usage, imageCount, imageOutputSizes, firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
} }
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval) logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval)
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
return usage, imageCount, firstTokenMs, fmt.Errorf("image stream data interval timeout") return usage, imageCount, imageOutputSizes, firstTokenMs, fmt.Errorf("image stream data interval timeout")
case <-keepaliveCh: case <-keepaliveCh:
if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
continue continue
@ -1019,31 +1038,34 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
var ( var (
usage OpenAIUsage usage OpenAIUsage
imageCount int imageCount int
firstTokenMs *int imageOutputSizes []string
firstTokenMs *int
) )
if parsed.Stream { if parsed.Stream {
usage, imageCount, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel) usage, imageCount, imageOutputSizes, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel)
if err != nil { if err != nil {
if imageCount > 0 { if imageCount > 0 {
return &OpenAIForwardResult{ return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"), RequestID: resp.Header.Get("x-request-id"),
Usage: usage, Usage: usage,
Model: requestModel, Model: requestModel,
UpstreamModel: requestModel, UpstreamModel: requestModel,
Stream: parsed.Stream, Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(), ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount, ImageCount: imageCount,
ImageSize: parsed.SizeTier, ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: imageOutputSizes,
}, err }, err
} }
return nil, err return nil, err
} }
} else { } else {
usage, imageCount, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel) usage, imageCount, imageOutputSizes, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1052,15 +1074,17 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
imageCount = parsed.N imageCount = parsed.N
} }
return &OpenAIForwardResult{ return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"), RequestID: resp.Header.Get("x-request-id"),
Usage: usage, Usage: usage,
Model: requestModel, Model: requestModel,
UpstreamModel: requestModel, UpstreamModel: requestModel,
Stream: parsed.Stream, Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(), ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount, ImageCount: imageCount,
ImageSize: parsed.SizeTier, ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: imageOutputSizes,
}, nil }, nil
} }

View File

@ -149,9 +149,9 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCusto
{size: "2048x1152", wantTier: "2K"}, {size: "2048x1152", wantTier: "2K"},
{size: "3840x2160", wantTier: "4K"}, {size: "3840x2160", wantTier: "4K"},
{size: "2160x3840", wantTier: "4K"}, {size: "2160x3840", wantTier: "4K"},
{size: "1024X768", wantTier: "2K"}, {size: "1024X768", wantTier: "1K"},
{size: "1280x768", wantTier: "2K"}, {size: "1280x768", wantTier: "2K"},
{size: "2560x1440", wantTier: "2K"}, {size: "2560x1440", wantTier: "4K"},
{size: "2560x1600", wantTier: "4K"}, {size: "2560x1600", wantTier: "4K"},
{size: "auto", wantTier: "2K"}, {size: "auto", wantTier: "2K"},
} }
@ -186,7 +186,7 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_UnknownSizesDoNotBlockPass
{size: "2048x1153", wantTier: "2K"}, {size: "2048x1153", wantTier: "2K"},
{size: "4096x1024", wantTier: "4K"}, {size: "4096x1024", wantTier: "4K"},
{size: "3840x1024", wantTier: "4K"}, {size: "3840x1024", wantTier: "4K"},
{size: "512x512", wantTier: "2K"}, {size: "512x512", wantTier: "1K"},
{size: "invalid", wantTier: "2K"}, {size: "invalid", wantTier: "2K"},
{size: "999999999999999999999999999x2", wantTier: "2K"}, {size: "999999999999999999999999999x2", wantTier: "2K"},
} }

View File

@ -2351,18 +2351,19 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
) )
return &OpenAIForwardResult{ return &OpenAIForwardResult{
RequestID: responseID, RequestID: responseID,
Usage: *usage, Usage: *usage,
Model: originalModel, Model: originalModel,
UpstreamModel: mappedModel, UpstreamModel: mappedModel,
ImageCount: imageCounter.Count(), ImageCount: imageCounter.Count(),
ServiceTier: extractOpenAIServiceTier(reqBody), ImageOutputSizes: imageCounter.Sizes(),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), ServiceTier: extractOpenAIServiceTier(reqBody),
Stream: reqStream, ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel),
OpenAIWSMode: true, Stream: reqStream,
ResponseHeaders: lease.HandshakeHeaders(), OpenAIWSMode: true,
Duration: time.Since(startTime), ResponseHeaders: lease.HandshakeHeaders(),
FirstTokenMs: firstTokenMs, Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}, nil }, nil
} }
@ -2464,6 +2465,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
originalModel string originalModel string
imageBillingModel string imageBillingModel string
imageSizeTier string imageSizeTier string
imageInputSize string
payloadBytes int payloadBytes int
} }
@ -2567,12 +2569,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
} }
imageBillingModel := "" imageBillingModel := ""
imageSizeTier := "" imageSizeTier := ""
imageInputSize := ""
if imageIntent { if imageIntent {
var imageCfgErr error var imageCfgErr error
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(normalized, originalModel) imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(normalized, originalModel)
if imageCfgErr != nil { if imageCfgErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr) return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr)
} }
imageBillingModel = imageCfg.Model
imageSizeTier = imageCfg.SizeTier
imageInputSize = imageCfg.InputSize
} }
// Apply OpenAI Fast Policy on the response.create frame using the same // Apply OpenAI Fast Policy on the response.create frame using the same
@ -2621,6 +2627,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
originalModel: originalModel, originalModel: originalModel,
imageBillingModel: imageBillingModel, imageBillingModel: imageBillingModel,
imageSizeTier: imageSizeTier, imageSizeTier: imageSizeTier,
imageInputSize: imageInputSize,
payloadBytes: len(normalized), payloadBytes: len(normalized),
}, nil }, nil
} }
@ -2822,7 +2829,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return payload, nil return payload, nil
} }
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string) (*OpenAIForwardResult, error) { sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) {
if lease == nil { if lease == nil {
return nil, errors.New("upstream websocket lease is nil") return nil, errors.New("upstream websocket lease is nil")
} }
@ -3046,6 +3053,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if imageCount > 0 { if imageCount > 0 {
result.ImageCount = imageCount result.ImageCount = imageCount
result.ImageSize = imageSizeTier result.ImageSize = imageSizeTier
result.ImageInputSize = imageInputSize
result.ImageOutputSizes = imageCounter.Sizes()
result.BillingModel = imageBillingModel result.BillingModel = imageBillingModel
} }
return result, nil return result, nil
@ -3057,6 +3066,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
currentOriginalModel := firstPayload.originalModel currentOriginalModel := firstPayload.originalModel
currentImageBillingModel := firstPayload.imageBillingModel currentImageBillingModel := firstPayload.imageBillingModel
currentImageSizeTier := firstPayload.imageSizeTier currentImageSizeTier := firstPayload.imageSizeTier
currentImageInputSize := firstPayload.imageInputSize
currentPayloadBytes := firstPayload.payloadBytes currentPayloadBytes := firstPayload.payloadBytes
isStrictAffinityTurn := func(payload []byte) bool { isStrictAffinityTurn := func(payload []byte) bool {
if !storeDisabled { if !storeDisabled {
@ -3534,7 +3544,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
) )
} }
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier) result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize)
if relayErr != nil { if relayErr != nil {
lastTurnClean = false lastTurnClean = false
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
@ -3658,6 +3668,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
currentOriginalModel = nextPayload.originalModel currentOriginalModel = nextPayload.originalModel
currentImageBillingModel = nextPayload.imageBillingModel currentImageBillingModel = nextPayload.imageBillingModel
currentImageSizeTier = nextPayload.imageSizeTier currentImageSizeTier = nextPayload.imageSizeTier
currentImageInputSize = nextPayload.imageInputSize
currentPayloadBytes = nextPayload.payloadBytes currentPayloadBytes = nextPayload.payloadBytes
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
if !storeDisabled { if !storeDisabled {

View File

@ -162,9 +162,13 @@ type UsageLog struct {
CacheTTLOverridden bool CacheTTLOverridden bool
// 图片生成字段 // 图片生成字段
ImageCount int ImageCount int
ImageSize *string ImageSize *string
MediaType *string ImageInputSize *string
ImageOutputSize *string
ImageSizeSource *string
ImageSizeBreakdown map[string]int
MediaType *string
CreatedAt time.Time CreatedAt time.Time

View File

@ -0,0 +1,51 @@
-- Add generated-image billing size audit metadata.
-- `image_size` remains the canonical billing tier used for cost calculation.
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS image_input_size VARCHAR(32);
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS image_output_size VARCHAR(32);
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS image_size_source VARCHAR(16);
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS image_size_breakdown JSONB;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'usage_logs_image_size_source_check'
AND conrelid = 'usage_logs'::regclass
) THEN
ALTER TABLE usage_logs
ADD CONSTRAINT usage_logs_image_size_source_check
CHECK (
image_size_source IS NULL
OR image_size_source IN ('output', 'input', 'default', 'legacy')
) NOT VALID;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'usage_logs_image_billing_size_check'
AND conrelid = 'usage_logs'::regclass
) THEN
ALTER TABLE usage_logs
ADD CONSTRAINT usage_logs_image_billing_size_check
CHECK (
image_count <= 0
OR (
image_size IS NOT NULL
AND image_size IN ('1K', '2K', '4K', 'mixed')
)
) NOT VALID;
END IF;
END $$;

View File

@ -86,19 +86,19 @@
</template> </template>
<template #cell-billing_mode="{ row }"> <template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(row.billing_mode)"> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
{{ getBillingModeLabel(row.billing_mode, t) }} {{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
</span> </span>
</template> </template>
<template #cell-tokens="{ row }"> <template #cell-tokens="{ row }">
<!-- 图片生成请求仅按次计费时显示图片格式 --> <!-- 图片生成请求仅按次计费时显示图片格式 -->
<div v-if="row.image_count > 0 && row.billing_mode === BILLING_MODE_IMAGE" class="flex items-center gap-1.5"> <div v-if="isImageUsage(row)" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span> <span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
</div> </div>
<!-- Token 请求 --> <!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5"> <div v-else class="flex items-center gap-1.5">
@ -280,21 +280,30 @@
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<!-- Token billing: show unit prices per 1M tokens --> <!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN"> <template v-if="tooltipData && isImageUsage(tooltipData)">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === BILLING_MODE_IMAGE">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span> <span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})</span> <span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageBillingSize') }}</span>
<span class="font-medium text-white">{{ formatImageBillingSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeSource') }}</span>
<span class="font-medium text-white">{{ formatImageSizeSource(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputSize') }}</span>
<span class="font-medium text-white">{{ formatImageInputSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputSize') }}</span>
<span class="font-medium text-white">{{ formatImageOutputSize(tooltipData, t) }}</span>
</div>
<div v-if="formatImageSizeBreakdown(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeBreakdown') }}</span>
<span class="font-medium text-white">{{ formatImageSizeBreakdown(tooltipData) }}</span>
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span> <span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
@ -305,6 +314,16 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
</template> </template>
<template v-else-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4"> <div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span> <span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@ -366,6 +385,13 @@ import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType' import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode' import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */ /** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number { function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
@ -381,6 +407,17 @@ function imageUnitPrice(row: AdminUsageLog | null): number {
return Number.isFinite(price) ? price : 0 return Number.isFinite(price) ? price : 0
} }
function isImageUsage(row: Pick<AdminUsageLog, 'image_count'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0
}
function getDisplayBillingMode(row: Pick<AdminUsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'

View File

@ -22,6 +22,26 @@ const messages: Record<string, string> = {
'usage.original': 'Original', 'usage.original': 'Original',
'usage.userBilled': 'User billed', 'usage.userBilled': 'User billed',
'usage.accountBilled': 'Account billed', 'usage.accountBilled': 'Account billed',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
} }
vi.mock('vue-i18n', async () => { vi.mock('vue-i18n', async () => {
@ -40,12 +60,42 @@ const DataTableStub = {
<div> <div>
<div v-for="row in data" :key="row.request_id"> <div v-for="row in data" :key="row.request_id">
<slot name="cell-model" :row="row" :value="row.model" /> <slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" /> <slot name="cell-cost" :row="row" />
</div> </div>
</div> </div>
`, `,
} }
const baseImageRow = {
request_id: 'req-admin-image',
model: 'gpt-image-2',
actual_cost: 0.4,
total_cost: 0.4,
account_rate_multiplier: 1,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
cache_ttl_overridden: false,
billing_mode: 'image',
image_count: 2,
image_size: '2K',
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
}
describe('admin UsageTable tooltip', () => { describe('admin UsageTable tooltip', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
@ -93,7 +143,8 @@ describe('admin UsageTable tooltip', () => {
}, },
}) })
await wrapper.find('.group.relative').trigger('mouseenter') const tooltipTriggers = wrapper.findAll('.group.relative')
await tooltipTriggers[tooltipTriggers.length - 1].trigger('mouseenter')
await nextTick() await nextTick()
const text = wrapper.text() const text = wrapper.text()
@ -147,4 +198,126 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4') expect(text).toContain('claude-sonnet-4')
expect(text).toContain('claude-sonnet-4-20250514') expect(text).toContain('claude-sonnet-4-20250514')
}) })
it.each([
{
name: 'defaulted row',
row: {
...baseImageRow,
request_id: 'req-admin-default-image',
image_size: '2K',
image_input_size: 'auto',
image_output_size: null,
image_size_source: 'default',
},
expected: ['2K', 'Default billing tier', 'auto', 'unknown'],
},
{
name: 'output-sourced row',
row: {
...baseImageRow,
request_id: 'req-admin-output-image',
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 1 },
},
expected: ['4K', 'Upstream output', '1024x1024', '3840x2160', '4K x 1'],
},
{
name: 'input-sourced row',
row: {
...baseImageRow,
request_id: 'req-admin-input-image',
image_size: '1K',
image_input_size: '1024x1024',
image_output_size: null,
image_size_source: 'input',
},
expected: ['1K', 'Request input', '1024x1024', 'unknown'],
},
{
name: 'legacy unstandardized row',
row: {
...baseImageRow,
request_id: 'req-admin-legacy-unstandardized-image',
image_size: '512x512',
image_input_size: null,
image_output_size: null,
image_size_source: null,
},
expected: ['legacy unstandardized: 512x512', 'Legacy record', 'unknown'],
},
])('shows image usage metadata for $name', async ({ row, expected }) => {
const wrapper = mount(UsageTable, {
props: {
data: [row],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('Size source')
expect(text).toContain('Input size')
expect(text).toContain('Output size')
expect(text).toContain('Per-image price')
expect(text).toContain('Image total price')
for (const value of expected) {
expect(text).toContain(value)
}
})
it('displays historical image rows with missing billing_mode as image usage without a 2K fallback', async () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{
...baseImageRow,
request_id: 'req-admin-legacy-missing-image',
billing_mode: null,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
},
],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('Image count')
expect(text).toContain('Per-image price')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
}) })

View File

@ -855,6 +855,19 @@ export default {
imageUnitPrice: 'Per-image price', imageUnitPrice: 'Per-image price',
imageTotalPrice: 'Image total price', imageTotalPrice: 'Image total price',
imageCount: 'Image count', imageCount: 'Image count',
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
imageSizeSource: 'Size source',
imageSizeBreakdown: 'Size breakdown',
imageSizeSourceOutput: 'Upstream output',
imageSizeSourceInput: 'Request input',
imageSizeSourceDefault: 'Default billing tier',
imageSizeSourceLegacy: 'Legacy record',
imageSizeSourceMissing: 'Not recorded',
imageSizeNotRecorded: 'not recorded',
imageSizeLegacyUnstandardized: 'legacy unstandardized',
imageSizeUnknown: 'unknown',
cacheRead: 'Read', cacheRead: 'Read',
cacheWrite: 'Write', cacheWrite: 'Write',
serviceTier: 'Service tier', serviceTier: 'Service tier',

View File

@ -859,6 +859,19 @@ export default {
imageUnitPrice: '单张价格', imageUnitPrice: '单张价格',
imageTotalPrice: '图片总价', imageTotalPrice: '图片总价',
imageCount: '图片张数', imageCount: '图片张数',
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
imageSizeSourceInput: '请求输入',
imageSizeSourceDefault: '默认计费档位',
imageSizeSourceLegacy: '历史记录',
imageSizeSourceMissing: '未记录',
imageSizeNotRecorded: '未记录',
imageSizeLegacyUnstandardized: '历史非标准',
imageSizeUnknown: '未知',
cacheRead: '读取', cacheRead: '读取',
cacheWrite: '写入', cacheWrite: '写入',
serviceTier: '服务档位', serviceTier: '服务档位',

View File

@ -1154,6 +1154,8 @@ export interface CodexSessionImportResult {
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2' export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2'
export type ImageSizeSource = 'output' | 'input' | 'default' | 'legacy'
export type ImageSizeBreakdown = Record<string, number>
export interface UsageLog { export interface UsageLog {
id: number id: number
@ -1195,6 +1197,10 @@ export interface UsageLog {
// 图片生成字段 // 图片生成字段
image_count: number image_count: number
image_size: string | null image_size: string | null
image_input_size: string | null
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
// User-Agent // User-Agent
user_agent: string | null user_agent: string | null

View File

@ -0,0 +1,56 @@
import type { UsageLog } from '@/types'
type Translate = (key: string) => string
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
type ImageUsageRow = Pick<
UsageLog,
'image_size' | 'image_input_size' | 'image_output_size' | 'image_size_source' | 'image_size_breakdown'
>
const trimmed = (value: string | null | undefined): string => value?.trim() ?? ''
export const formatImageBillingSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_size)
if (!size) {
return t('usage.imageSizeNotRecorded')
}
if (knownImageBillingSizes.has(size)) {
return size
}
return `${t('usage.imageSizeLegacyUnstandardized')}: ${size}`
}
export const formatImageInputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_input_size)
return size || t('usage.imageSizeUnknown')
}
export const formatImageOutputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_output_size)
return size || t('usage.imageSizeUnknown')
}
export const formatImageSizeSource = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const source = trimmed(row?.image_size_source).toLowerCase()
if (knownImageSizeSources.has(source)) {
return t(`usage.imageSizeSource${source.charAt(0).toUpperCase()}${source.slice(1)}`)
}
if (trimmed(row?.image_size)) {
return t('usage.imageSizeSourceLegacy')
}
return t('usage.imageSizeSourceMissing')
}
export const formatImageSizeBreakdown = (row: ImageUsageRow | null | undefined): string => {
const breakdown = row?.image_size_breakdown
if (!breakdown || Object.keys(breakdown).length === 0) {
return ''
}
return ['1K', '2K', '4K']
.filter((tier) => (breakdown[tier] ?? 0) > 0)
.map((tier) => `${tier} x ${breakdown[tier]}`)
.join(', ')
}

View File

@ -191,14 +191,14 @@
<template #cell-billing_mode="{ row }"> <template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium" <span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:class="getBillingModeBadgeClass(row.billing_mode)"> :class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
{{ getBillingModeLabel(row.billing_mode, t) }} {{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
</span> </span>
</template> </template>
<template #cell-tokens="{ row }"> <template #cell-tokens="{ row }">
<!-- 图片生成请求仅按次计费时显示图片格式 --> <!-- 图片生成请求 -->
<div v-if="row.image_count > 0 && row.billing_mode === 'image'" class="flex items-center gap-1.5"> <div v-if="isImageUsage(row)" class="flex items-center gap-1.5">
<svg <svg
class="h-4 w-4 text-indigo-500" class="h-4 w-4 text-indigo-500"
fill="none" fill="none"
@ -212,8 +212,8 @@
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
</svg> </svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ $t('usage.imageUnit') }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span> <span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
</div> </div>
<!-- Token 请求 --> <!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5"> <div v-else class="flex items-center gap-1.5">
@ -447,22 +447,31 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<!-- Token billing: show unit prices per 1M tokens --> <!-- Per-image billing: show image metadata and unit price -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'"> <template v-if="tooltipData && isImageUsage(tooltipData)">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === 'image'">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span> <span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})</span> <span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageBillingSize') }}</span>
<span class="font-medium text-white">{{ formatImageBillingSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeSource') }}</span>
<span class="font-medium text-white">{{ formatImageSizeSource(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputSize') }}</span>
<span class="font-medium text-white">{{ formatImageInputSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputSize') }}</span>
<span class="font-medium text-white">{{ formatImageOutputSize(tooltipData, t) }}</span>
</div>
<div v-if="formatImageSizeBreakdown(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeBreakdown') }}</span>
<span class="font-medium text-white">{{ formatImageSizeBreakdown(tooltipData) }}</span>
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span> <span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
@ -473,6 +482,17 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
</template> </template>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-else-if="!getDisplayBillingMode(tooltipData) || getDisplayBillingMode(tooltipData) === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4"> <div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span> <span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@ -538,7 +558,19 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType' import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode' import {
BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
getBillingModeBadgeClass,
getBillingModeLabel,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
} from '@/utils/imageUsage'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
@ -646,6 +678,17 @@ const imageUnitPrice = (row: UsageLog | null): number => {
return Number.isFinite(price) ? price : 0 return Number.isFinite(price) ? price : 0
} }
const isImageUsage = (row: Pick<UsageLog, 'image_count'> | null | undefined): boolean => {
return (row?.image_count ?? 0) > 0
}
const getDisplayBillingMode = (row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined => {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
const formatUserAgent = (ua: string): string => { const formatUserAgent = (ua: string): string => {
return ua return ua
} }
@ -877,7 +920,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort), formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '', log.inbound_endpoint || '',
getRequestTypeExportText(log), getRequestTypeExportText(log),
getBillingModeLabel(log.billing_mode, t), getBillingModeLabel(getDisplayBillingMode(log), t),
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
log.cache_read_tokens, log.cache_read_tokens,

View File

@ -41,6 +41,26 @@ const messages: Record<string, string> = {
'usage.duration': 'Duration', 'usage.duration': 'Duration',
'usage.time': 'Time', 'usage.time': 'Time',
'usage.userAgent': 'User Agent', 'usage.userAgent': 'User Agent',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
} }
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
@ -69,7 +89,19 @@ vi.mock('vue-i18n', async () => {
const AppLayoutStub = { template: '<div><slot /></div>' } const AppLayoutStub = { template: '<div><slot /></div>' }
const TablePageLayoutStub = { const TablePageLayoutStub = {
template: '<div><slot name="actions" /><slot name="filters" /><slot /></div>', template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
} }
describe('user UsageView tooltip', () => { describe('user UsageView tooltip', () => {
@ -146,6 +178,7 @@ describe('user UsageView tooltip', () => {
EmptyState: true, EmptyState: true,
Select: true, Select: true,
DateRangePicker: true, DateRangePicker: true,
DataTable: DataTableStub,
Icon: true, Icon: true,
Teleport: true, Teleport: true,
}, },
@ -244,6 +277,7 @@ describe('user UsageView tooltip', () => {
EmptyState: true, EmptyState: true,
Select: true, Select: true,
DateRangePicker: true, DateRangePicker: true,
DataTable: DataTableStub,
Icon: true, Icon: true,
Teleport: true, Teleport: true,
}, },
@ -274,4 +308,233 @@ describe('user UsageView tooltip', () => {
window.URL.revokeObjectURL = originalRevokeObjectURL window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore() clickSpy.mockRestore()
}) })
it('exports historical image rows with image billing mode derived from image_count', async () => {
const exportedLogs = [
{
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
let exportedBlob: Blob | null = null
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob as Blob)
})
expect(csv).toContain('Billing Mode')
expect(csv).toContain('Image')
expect(csv).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('does not display a 2K fallback for historical image rows with missing size', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-legacy-missing-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
it('shows image billing metadata in the user cost tooltip', async () => {
query.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
avg_duration_ms: 0,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-output-image',
actual_cost: 0.8,
total_cost: 0.8,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
billing_mode: null,
image_count: 2,
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 2 },
}
setupState.tooltipVisible = true
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('4K')
expect(text).toContain('Size source')
expect(text).toContain('Upstream output')
expect(text).toContain('Input size')
expect(text).toContain('1024x1024')
expect(text).toContain('Output size')
expect(text).toContain('3840x2160')
expect(text).toContain('4K x 2')
})
}) })