Fix image billing size normalization
This commit is contained in:
parent
62ccd0ff39
commit
bb4c1abe28
@ -1318,6 +1318,10 @@ var (
|
||||
{Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45},
|
||||
{Name: "image_count", Type: field.TypeInt, Default: 0},
|
||||
{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: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "api_key_id", Type: field.TypeInt64},
|
||||
@ -1334,31 +1338,31 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "usage_logs_api_keys_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[33]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[37]},
|
||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_accounts_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[34]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[38]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_groups_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[35]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[39]},
|
||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_users_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[36]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[37]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@ -1367,32 +1371,32 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[36]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[33]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[37]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[34]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[38]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[35]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[39]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_subscription_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[37]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[32]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[36]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_model",
|
||||
@ -1412,17 +1416,17 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[36], UsageLogsColumns[32]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[33], UsageLogsColumns[32]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[35], UsageLogsColumns[32]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -34260,6 +34260,10 @@ type UsageLogMutation struct {
|
||||
image_count *int
|
||||
addimage_count *int
|
||||
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
|
||||
created_at *time.Time
|
||||
clearedFields map[string]struct{}
|
||||
@ -36202,6 +36206,202 @@ func (m *UsageLogMutation) ResetImageSize() {
|
||||
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.
|
||||
func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) {
|
||||
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
|
||||
// AddedFields().
|
||||
func (m *UsageLogMutation) Fields() []string {
|
||||
fields := make([]string, 0, 37)
|
||||
fields := make([]string, 0, 41)
|
||||
if m.user != nil {
|
||||
fields = append(fields, usagelog.FieldUserID)
|
||||
}
|
||||
@ -36549,6 +36749,18 @@ func (m *UsageLogMutation) Fields() []string {
|
||||
if m.image_size != nil {
|
||||
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 {
|
||||
fields = append(fields, usagelog.FieldCacheTTLOverridden)
|
||||
}
|
||||
@ -36633,6 +36845,14 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.ImageCount()
|
||||
case usagelog.FieldImageSize:
|
||||
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:
|
||||
return m.CacheTTLOverridden()
|
||||
case usagelog.FieldCreatedAt:
|
||||
@ -36716,6 +36936,14 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
||||
return m.OldImageCount(ctx)
|
||||
case usagelog.FieldImageSize:
|
||||
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:
|
||||
return m.OldCacheTTLOverridden(ctx)
|
||||
case usagelog.FieldCreatedAt:
|
||||
@ -36974,6 +37202,34 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetImageSize(v)
|
||||
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:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@ -37291,6 +37547,18 @@ func (m *UsageLogMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(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
|
||||
}
|
||||
|
||||
@ -37347,6 +37615,18 @@ func (m *UsageLogMutation) ClearField(name string) error {
|
||||
case usagelog.FieldImageSize:
|
||||
m.ClearImageSize()
|
||||
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)
|
||||
}
|
||||
@ -37460,6 +37740,18 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
||||
case usagelog.FieldImageSize:
|
||||
m.ResetImageSize()
|
||||
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:
|
||||
m.ResetCacheTTLOverridden()
|
||||
return nil
|
||||
|
||||
@ -1722,12 +1722,24 @@ func init() {
|
||||
usagelogDescImageSize := usagelogFields[34].Descriptor()
|
||||
// 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)
|
||||
// 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 := usagelogFields[35].Descriptor()
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor()
|
||||
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
|
||||
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
|
||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||
usagelogDescCreatedAt := usagelogFields[36].Descriptor()
|
||||
usagelogDescCreatedAt := usagelogFields[40].Descriptor()
|
||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||
userMixin := schema.User{}.Mixin()
|
||||
|
||||
@ -134,6 +134,21 @@ func (UsageLog) Fields() []ent.Field {
|
||||
MaxLen(10).
|
||||
Optional().
|
||||
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 计费)
|
||||
field.Bool("cache_ttl_overridden").
|
||||
Default(false),
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -92,6 +93,14 @@ type UsageLog struct {
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
// ImageSize holds the value of the "image_size" field.
|
||||
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 bool `json:"cache_ttl_overridden,omitempty"`
|
||||
// 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))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case usagelog.FieldImageSizeBreakdown:
|
||||
values[i] = new([]byte)
|
||||
case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
values[i] = new(sql.NullBool)
|
||||
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount:
|
||||
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)
|
||||
case usagelog.FieldCreatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@ -434,6 +445,35 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
||||
_m.ImageSize = new(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:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
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(", ")
|
||||
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(fmt.Sprintf("%v", _m.CacheTTLOverridden))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@ -84,6 +84,14 @@ const (
|
||||
FieldImageCount = "image_count"
|
||||
// FieldImageSize holds the string denoting the image_size field in the database.
|
||||
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 = "cache_ttl_overridden"
|
||||
// FieldCreatedAt holds the string denoting the created_at field in the database.
|
||||
@ -175,6 +183,10 @@ var Columns = []string{
|
||||
FieldIPAddress,
|
||||
FieldImageCount,
|
||||
FieldImageSize,
|
||||
FieldImageInputSize,
|
||||
FieldImageOutputSize,
|
||||
FieldImageSizeSource,
|
||||
FieldImageSizeBreakdown,
|
||||
FieldCacheTTLOverridden,
|
||||
FieldCreatedAt,
|
||||
}
|
||||
@ -242,6 +254,12 @@ var (
|
||||
DefaultImageCount int
|
||||
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
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 bool
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc()
|
||||
|
||||
@ -230,6 +230,21 @@ func ImageSize(v string) predicate.UsageLog {
|
||||
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.
|
||||
func CacheTTLOverridden(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
|
||||
@ -1900,6 +1915,241 @@ func ImageSizeContainsFold(v string) predicate.UsageLog {
|
||||
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.
|
||||
func CacheTTLOverriddenEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
|
||||
|
||||
@ -477,6 +477,54 @@ func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate {
|
||||
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.
|
||||
func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate {
|
||||
_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)}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
_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 {
|
||||
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||
_node.CacheTTLOverridden = value
|
||||
@ -1679,6 +1758,78 @@ func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert {
|
||||
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.
|
||||
func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert {
|
||||
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.
|
||||
func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne {
|
||||
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.
|
||||
func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
|
||||
@ -739,6 +739,78 @@ func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate {
|
||||
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.
|
||||
func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate {
|
||||
_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)}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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() {
|
||||
_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 {
|
||||
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||
}
|
||||
@ -1974,6 +2085,78 @@ func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne {
|
||||
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.
|
||||
func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne {
|
||||
_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)}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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() {
|
||||
_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 {
|
||||
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@ -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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
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/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
|
||||
@ -600,6 +600,10 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
FirstTokenMs: l.FirstTokenMs,
|
||||
ImageCount: l.ImageCount,
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
ImageSizeBreakdown: l.ImageSizeBreakdown,
|
||||
MediaType: l.MediaType,
|
||||
UserAgent: l.UserAgent,
|
||||
CacheTTLOverridden: l.CacheTTLOverridden,
|
||||
|
||||
@ -148,6 +148,65 @@ func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *
|
||||
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 {
|
||||
return &value
|
||||
}
|
||||
|
||||
@ -400,9 +400,13 @@ type UsageLog struct {
|
||||
FirstTokenMs *int `json:"first_token_ms"`
|
||||
|
||||
// 图片生成字段
|
||||
ImageCount int `json:"image_count"`
|
||||
ImageSize *string `json:"image_size"`
|
||||
MediaType *string `json:"media_type"`
|
||||
ImageCount int `json:"image_count"`
|
||||
ImageSize *string `json:"image_size"`
|
||||
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
|
||||
UserAgent *string `json:"user_agent"`
|
||||
|
||||
@ -58,7 +58,7 @@ func TestResolvePageImagePath(t *testing.T) {
|
||||
if !ok {
|
||||
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 {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
@ -67,7 +67,7 @@ func TestResolvePageImagePath(t *testing.T) {
|
||||
if !ok {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -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", "request_type", "smallint", 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
|
||||
var usageBillingDedupRegclass sql.NullString
|
||||
|
||||
@ -28,7 +28,7 @@ import (
|
||||
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:
|
||||
// 1. prepareUsageLogInsert().args
|
||||
@ -73,6 +73,10 @@ var usageLogInsertArgTypes = [...]string{
|
||||
"text", // ip_address
|
||||
"integer", // image_count
|
||||
"text", // image_size
|
||||
"text", // image_input_size
|
||||
"text", // image_output_size
|
||||
"text", // image_size_source
|
||||
"jsonb", // image_size_breakdown
|
||||
"text", // service_tier
|
||||
"text", // reasoning_effort
|
||||
"text", // inbound_endpoint
|
||||
@ -120,6 +124,24 @@ func appendRawUsageLogModelWhereCondition(conditions []string, args []any, model
|
||||
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
|
||||
// compatibility with historical rows. Requested/upstream analytics must use
|
||||
// resolveModelDimensionExpression instead.
|
||||
@ -352,6 +374,10 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -369,7 +395,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46
|
||||
$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
|
||||
RETURNING id, created_at
|
||||
@ -790,6 +816,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -803,7 +833,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(keys)*46)
|
||||
args := make([]any, 0, len(keys)*50)
|
||||
argPos := 1
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
@ -867,6 +897,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -915,6 +949,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -1003,6 +1041,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -1016,7 +1058,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(preparedList)*46)
|
||||
args := make([]any, 0, len(preparedList)*50)
|
||||
argPos := 1
|
||||
for idx, prepared := range preparedList {
|
||||
if idx > 0 {
|
||||
@ -1077,6 +1119,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -1125,6 +1171,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -1181,6 +1231,10 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
ip_address,
|
||||
image_count,
|
||||
image_size,
|
||||
image_input_size,
|
||||
image_output_size,
|
||||
image_size_source,
|
||||
image_size_breakdown,
|
||||
service_tier,
|
||||
reasoning_effort,
|
||||
inbound_endpoint,
|
||||
@ -1198,7 +1252,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46
|
||||
$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
|
||||
`, prepared.args...)
|
||||
@ -1225,6 +1279,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
userAgent := nullString(log.UserAgent)
|
||||
ipAddress := nullString(log.IPAddress)
|
||||
imageSize := nullString(log.ImageSize)
|
||||
imageInputSize := nullString(log.ImageInputSize)
|
||||
imageOutputSize := nullString(log.ImageOutputSize)
|
||||
imageSizeSource := nullString(log.ImageSizeSource)
|
||||
imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown)
|
||||
serviceTier := nullString(log.ServiceTier)
|
||||
reasoningEffort := nullString(log.ReasoningEffort)
|
||||
inboundEndpoint := nullString(log.InboundEndpoint)
|
||||
@ -1285,6 +1343,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
ipAddress,
|
||||
log.ImageCount,
|
||||
imageSize,
|
||||
imageInputSize,
|
||||
imageOutputSize,
|
||||
imageSizeSource,
|
||||
imageSizeBreakdown,
|
||||
serviceTier,
|
||||
reasoningEffort,
|
||||
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))
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
if filters.BillingMode != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1))
|
||||
args = append(args, filters.BillingMode)
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
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))
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
if filters.BillingMode != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("billing_mode = $%d", len(args)+1))
|
||||
args = append(args, filters.BillingMode)
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
@ -4084,6 +4140,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
ipAddress sql.NullString
|
||||
imageCount int
|
||||
imageSize sql.NullString
|
||||
imageInputSize sql.NullString
|
||||
imageOutputSize sql.NullString
|
||||
imageSizeSource sql.NullString
|
||||
imageSizeBreakdown sql.NullString
|
||||
serviceTier sql.NullString
|
||||
reasoningEffort sql.NullString
|
||||
inboundEndpoint sql.NullString
|
||||
@ -4134,6 +4194,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&ipAddress,
|
||||
&imageCount,
|
||||
&imageSize,
|
||||
&imageInputSize,
|
||||
&imageOutputSize,
|
||||
&imageSizeSource,
|
||||
&imageSizeBreakdown,
|
||||
&serviceTier,
|
||||
&reasoningEffort,
|
||||
&inboundEndpoint,
|
||||
@ -4212,6 +4276,16 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
if imageSize.Valid {
|
||||
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 {
|
||||
log.ServiceTier = &serviceTier.String
|
||||
}
|
||||
@ -4378,6 +4452,31 @@ func nullString(v *string) sql.NullString {
|
||||
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 {
|
||||
if v.Valid && strings.TrimSpace(v.String) != "" {
|
||||
return v.String
|
||||
|
||||
@ -76,6 +76,10 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
|
||||
sqlmock.AnyArg(), // ip_address
|
||||
log.ImageCount,
|
||||
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(), // reasoning_effort
|
||||
sqlmock.AnyArg(), // inbound_endpoint
|
||||
@ -155,6 +159,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
|
||||
sqlmock.AnyArg(),
|
||||
log.ImageCount,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), // image_input_size
|
||||
sqlmock.AnyArg(), // image_output_size
|
||||
sqlmock.AnyArg(), // image_size_source
|
||||
sqlmock.AnyArg(), // image_size_breakdown
|
||||
serviceTier,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
@ -230,12 +238,72 @@ func TestPrepareUsageLogInsert_ArgCountMatchesTypes(t *testing.T) {
|
||||
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) {
|
||||
require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{}, "fallback"))
|
||||
require.Equal(t, "fallback", coalesceTrimmedString(sql.NullString{Valid: true, String: " "}, "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 {
|
||||
out := make([]driver.Value, 0, len(values))
|
||||
for _, value := range values {
|
||||
@ -528,6 +596,63 @@ func (s usageLogScannerStub) Scan(dest ...any) error {
|
||||
}
|
||||
|
||||
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) {
|
||||
now := time.Now().UTC()
|
||||
log, err := scanUsageLog(usageLogScannerStub{values: []any{
|
||||
@ -567,6 +692,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullString{},
|
||||
0,
|
||||
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{},
|
||||
sql.NullString{},
|
||||
@ -615,6 +744,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullString{},
|
||||
0,
|
||||
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{},
|
||||
sql.NullString{},
|
||||
@ -663,6 +796,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullString{},
|
||||
0,
|
||||
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{},
|
||||
sql.NullString{},
|
||||
|
||||
@ -554,6 +554,10 @@ func TestAPIContracts(t *testing.T) {
|
||||
"first_token_ms": 50,
|
||||
"image_count": 0,
|
||||
"image_size": null,
|
||||
"image_input_size": null,
|
||||
"image_output_size": null,
|
||||
"image_size_source": null,
|
||||
"image_size_breakdown": null,
|
||||
"media_type": null,
|
||||
"cache_ttl_overridden": false,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
|
||||
@ -2094,7 +2094,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
}
|
||||
|
||||
// 解析请求以获取 image_size(用于图片计费)
|
||||
imageSize := s.extractImageSize(body)
|
||||
imageInputSize := s.extractImageInputSize(body)
|
||||
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
|
||||
|
||||
switch action {
|
||||
case "generateContent", "streamGenerateContent":
|
||||
@ -2465,6 +2466,7 @@ handleSuccess:
|
||||
ClientDisconnect: clientDisconnect,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
ImageInputSize: imageInputSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -4065,19 +4067,20 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
|
||||
// extractImageSize 从 Gemini 请求中提取 image_size 参数
|
||||
func (s *AntigravityGatewayService) extractImageSize(body []byte) string {
|
||||
return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body))
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) extractImageInputSize(body []byte) string {
|
||||
var req antigravity.GeminiRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return "2K" // 默认 2K
|
||||
return ""
|
||||
}
|
||||
|
||||
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
|
||||
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize))
|
||||
if size == "1K" || size == "2K" || size == "4K" {
|
||||
return size
|
||||
}
|
||||
return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)
|
||||
}
|
||||
|
||||
return "2K" // 默认 2K
|
||||
return ""
|
||||
}
|
||||
|
||||
// isImageGenerationModel 判断模型是否为图片生成模型
|
||||
|
||||
@ -809,6 +809,7 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag
|
||||
if imageCount <= 0 {
|
||||
return &CostBreakdown{}
|
||||
}
|
||||
imageSize = NormalizeImageBillingTierOrDefault(imageSize)
|
||||
|
||||
// 获取单价
|
||||
unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig)
|
||||
|
||||
@ -48,6 +48,21 @@ func TestCalculateImageCost_GroupCustomPricing(t *testing.T) {
|
||||
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 默认价格翻倍
|
||||
func TestCalculateImageCost_4KDoublePrice(t *testing.T) {
|
||||
svc := &BillingService{}
|
||||
|
||||
@ -192,6 +192,46 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin
|
||||
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) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
|
||||
@ -501,8 +501,13 @@ type ForwardResult struct {
|
||||
ReasoningEffort *string
|
||||
|
||||
// 图片生成计费字段(图片生成模型使用)
|
||||
ImageCount int // 生成的图片数量
|
||||
ImageSize string // 图片尺寸 "1K", "2K", "4K"
|
||||
ImageCount int // 生成的图片数量
|
||||
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.
|
||||
@ -8369,6 +8374,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
user := input.User
|
||||
account := input.Account
|
||||
subscription := input.Subscription
|
||||
ApplyForwardImageBillingResolution(result)
|
||||
|
||||
// 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens
|
||||
// 用于粘性会话切换时的特殊计费处理
|
||||
@ -8514,6 +8520,7 @@ func (s *GatewayService) calculateImageCost(
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
) *CostBreakdown {
|
||||
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
|
||||
tokens := UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
@ -8527,7 +8534,7 @@ func (s *GatewayService) calculateImageCost(
|
||||
GroupID: &gid,
|
||||
Tokens: tokens,
|
||||
RequestCount: result.ImageCount,
|
||||
SizeTier: result.ImageSize,
|
||||
SizeTier: sizeTier,
|
||||
RateMultiplier: multiplier,
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
@ -8547,7 +8554,7 @@ func (s *GatewayService) calculateImageCost(
|
||||
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 决定走普通/长上下文/渠道统一计费。
|
||||
@ -8648,6 +8655,10 @@ func (s *GatewayService) buildRecordUsageLog(
|
||||
FirstTokenMs: result.FirstTokenMs,
|
||||
ImageCount: result.ImageCount,
|
||||
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
|
||||
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
|
||||
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
|
||||
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
|
||||
ImageSizeBreakdown: result.ImageSizeBreakdown,
|
||||
CacheTTLOverridden: cacheTTLOverridden,
|
||||
ChannelID: optionalInt64Ptr(input.ChannelID),
|
||||
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
|
||||
|
||||
@ -1072,21 +1072,23 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
|
||||
// 图片生成计费
|
||||
imageCount := 0
|
||||
imageSize := s.extractImageSize(body)
|
||||
imageInputSize := s.extractImageInputSize(body)
|
||||
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
|
||||
if isImageGenerationModel(originalModel) {
|
||||
imageCount = 1
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
Stream: req.Stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
Stream: req.Stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
ImageInputSize: imageInputSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1600,21 +1602,23 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
|
||||
// 图片生成计费
|
||||
imageCount := 0
|
||||
imageSize := s.extractImageSize(body)
|
||||
imageInputSize := s.extractImageInputSize(body)
|
||||
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
|
||||
if isImageGenerationModel(originalModel) {
|
||||
imageCount = 1
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
Stream: stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
Stream: stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
ImageInputSize: imageInputSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -3432,6 +3436,10 @@ func convertClaudeGenerationConfig(req map[string]any) map[string]any {
|
||||
|
||||
// extractImageSize 从 Gemini 请求中提取 image_size 参数
|
||||
func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string {
|
||||
return normalizeOpenAIImageSizeTier(s.extractImageInputSize(body))
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) extractImageInputSize(body []byte) string {
|
||||
var req struct {
|
||||
GenerationConfig *struct {
|
||||
ImageConfig *struct {
|
||||
@ -3440,15 +3448,12 @@ func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string {
|
||||
} `json:"generationConfig"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return "2K"
|
||||
return ""
|
||||
}
|
||||
|
||||
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
|
||||
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize))
|
||||
if size == "1K" || size == "2K" || size == "4K" {
|
||||
return size
|
||||
}
|
||||
return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)
|
||||
}
|
||||
|
||||
return "2K"
|
||||
return ""
|
||||
}
|
||||
|
||||
260
backend/internal/service/image_billing_size.go
Normal file
260
backend/internal/service/image_billing_size.go
Normal 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
|
||||
}
|
||||
110
backend/internal/service/image_billing_size_test.go
Normal file
110
backend/internal/service/image_billing_size_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -170,7 +170,21 @@ func cloneRequestMapForImageIntent(body []byte) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
type OpenAIResponsesImageBillingConfig struct {
|
||||
Model string
|
||||
SizeTier string
|
||||
InputSize string
|
||||
}
|
||||
|
||||
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 := ""
|
||||
imageSize := ""
|
||||
hasImageTool := false
|
||||
@ -203,12 +217,24 @@ func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackMo
|
||||
imageModel = strings.TrimSpace(fallbackModel)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
return resolveOpenAIResponsesImageBillingConfig(reqBody, fallbackModel)
|
||||
return resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, fallbackModel)
|
||||
}
|
||||
|
||||
func isOpenAIImageBillingModelAlias(model string) bool {
|
||||
|
||||
@ -140,9 +140,10 @@ func TestResolveOpenAIResponsesImageBillingConfigDoesNotRejectUnknownSizes(t *te
|
||||
func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) {
|
||||
counter := newOpenAIImageOutputCounter()
|
||||
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.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.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","size":"3840x2160"}]}}`))
|
||||
require.Equal(t, 2, counter.Count())
|
||||
require.Equal(t, []string{"1024x1024", "3840x2160"}, counter.Sizes())
|
||||
}
|
||||
|
||||
func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) {
|
||||
@ -182,3 +183,36 @@ func TestOpenAIImageOutputCounterFallsBackForInvalidMultilineSSEBody(t *testing.
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
@ -10,12 +10,18 @@ import (
|
||||
|
||||
type openAIImageOutputCounter struct {
|
||||
seen map[string]struct{}
|
||||
seenSizes map[string]string
|
||||
seenOrder []string
|
||||
dataSizes []string
|
||||
count int
|
||||
maxDataCount int
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -28,6 +34,25 @@ func (c *openAIImageOutputCounter) Count() int {
|
||||
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) {
|
||||
if c == nil || len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return
|
||||
@ -73,10 +98,20 @@ func (c *openAIImageOutputCounter) addDataArray(data gjson.Result) {
|
||||
if !data.IsArray() {
|
||||
return
|
||||
}
|
||||
count := len(data.Array())
|
||||
items := data.Array()
|
||||
count := len(items)
|
||||
if count > c.maxDataCount {
|
||||
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) {
|
||||
@ -120,10 +155,18 @@ func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
size := strings.TrimSpace(item.Get("size").String())
|
||||
if _, exists := c.seen[key]; exists {
|
||||
if size != "" && strings.TrimSpace(c.seenSizes[key]) == "" {
|
||||
c.seenSizes[key] = size
|
||||
}
|
||||
return
|
||||
}
|
||||
c.seen[key] = struct{}{}
|
||||
c.seenOrder = append(c.seenOrder, key)
|
||||
if size != "" {
|
||||
c.seenSizes[key] = size
|
||||
}
|
||||
c.count++
|
||||
}
|
||||
|
||||
@ -142,8 +185,20 @@ func countOpenAIResponseImageOutputsFromJSONBytes(body []byte) int {
|
||||
return counter.Count()
|
||||
}
|
||||
|
||||
func collectOpenAIResponseImageOutputSizesFromJSONBytes(body []byte) []string {
|
||||
counter := newOpenAIImageOutputCounter()
|
||||
counter.AddJSONResponse(body)
|
||||
return counter.Sizes()
|
||||
}
|
||||
|
||||
func countOpenAIImageOutputsFromSSEBody(body string) int {
|
||||
counter := newOpenAIImageOutputCounter()
|
||||
counter.AddSSEBody(body)
|
||||
return counter.Count()
|
||||
}
|
||||
|
||||
func collectOpenAIImageOutputSizesFromSSEBody(body string) []string {
|
||||
counter := newOpenAIImageOutputCounter()
|
||||
counter.AddSSEBody(body)
|
||||
return counter.Sizes()
|
||||
}
|
||||
|
||||
@ -1320,6 +1320,93 @@ func TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists(t *testing.
|
||||
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) {
|
||||
imagePrice := 0.02
|
||||
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.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)
|
||||
}
|
||||
|
||||
@ -228,14 +228,19 @@ type OpenAIForwardResult struct {
|
||||
ServiceTier *string
|
||||
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix.
|
||||
// Stored for usage records display; nil means not provided / not applicable.
|
||||
ReasoningEffort *string
|
||||
Stream bool
|
||||
OpenAIWSMode bool
|
||||
ResponseHeaders http.Header
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int
|
||||
ImageCount int
|
||||
ImageSize string
|
||||
ReasoningEffort *string
|
||||
Stream bool
|
||||
OpenAIWSMode bool
|
||||
ResponseHeaders http.Header
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int
|
||||
ImageCount int
|
||||
ImageSize string
|
||||
ImageInputSize string
|
||||
ImageOutputSize string
|
||||
ImageOutputSizes []string
|
||||
ImageSizeSource string
|
||||
ImageSizeBreakdown map[string]int
|
||||
}
|
||||
|
||||
type OpenAIWSRetryMetricsSnapshot struct {
|
||||
@ -2416,9 +2421,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) {
|
||||
var imageCfgErr error
|
||||
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfig(reqBody, billingModel)
|
||||
imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
|
||||
if imageCfgErr != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@ -2430,6 +2436,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
})
|
||||
return nil, imageCfgErr
|
||||
}
|
||||
imageBillingModel = imageCfg.Model
|
||||
imageSizeTier = imageCfg.SizeTier
|
||||
imageInputSize = imageCfg.InputSize
|
||||
}
|
||||
|
||||
// Re-serialize body only if modified
|
||||
@ -2671,6 +2680,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
wsResult.UpstreamModel = upstreamModel
|
||||
if wsResult.ImageCount > 0 {
|
||||
wsResult.ImageSize = imageSizeTier
|
||||
wsResult.ImageInputSize = imageInputSize
|
||||
wsResult.BillingModel = imageBillingModel
|
||||
}
|
||||
return wsResult, nil
|
||||
@ -2777,6 +2787,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
var usage *OpenAIUsage
|
||||
var firstTokenMs *int
|
||||
imageCount := 0
|
||||
var imageOutputSizes []string
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
|
||||
if err != nil {
|
||||
@ -2785,6 +2796,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
usage = streamResult.usage
|
||||
firstTokenMs = streamResult.firstTokenMs
|
||||
imageCount = streamResult.imageCount
|
||||
imageOutputSizes = streamResult.imageOutputSizes
|
||||
} else {
|
||||
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
|
||||
if err != nil {
|
||||
@ -2792,6 +2804,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
usage = nonStreamResult.usage
|
||||
imageCount = nonStreamResult.imageCount
|
||||
imageOutputSizes = nonStreamResult.imageOutputSizes
|
||||
}
|
||||
|
||||
// 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 {
|
||||
forwardResult.ImageCount = imageCount
|
||||
forwardResult.ImageSize = imageSizeTier
|
||||
forwardResult.ImageInputSize = imageInputSize
|
||||
forwardResult.ImageOutputSizes = imageOutputSizes
|
||||
forwardResult.BillingModel = imageBillingModel
|
||||
}
|
||||
return forwardResult, nil
|
||||
@ -2927,9 +2942,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) {
|
||||
var imageCfgErr error
|
||||
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(body, reqModel)
|
||||
imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
|
||||
if imageCfgErr != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@ -2941,6 +2957,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
})
|
||||
return nil, imageCfgErr
|
||||
}
|
||||
imageBillingModel = imageCfg.Model
|
||||
imageSizeTier = imageCfg.SizeTier
|
||||
imageInputSize = imageCfg.InputSize
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.openai_gateway",
|
||||
@ -3026,6 +3045,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
var usage *OpenAIUsage
|
||||
var firstTokenMs *int
|
||||
imageCount := 0
|
||||
var imageOutputSizes []string
|
||||
if reqStream {
|
||||
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
|
||||
if err != nil {
|
||||
@ -3034,6 +3054,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
usage = result.usage
|
||||
firstTokenMs = result.firstTokenMs
|
||||
imageCount = result.imageCount
|
||||
imageOutputSizes = result.imageOutputSizes
|
||||
} else {
|
||||
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
|
||||
if err != nil {
|
||||
@ -3041,6 +3062,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
usage = result.usage
|
||||
imageCount = result.imageCount
|
||||
imageOutputSizes = result.imageOutputSizes
|
||||
}
|
||||
|
||||
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
|
||||
@ -3066,6 +3088,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
if imageCount > 0 {
|
||||
forwardResult.ImageCount = imageCount
|
||||
forwardResult.ImageSize = imageSizeTier
|
||||
forwardResult.ImageInputSize = imageInputSize
|
||||
forwardResult.ImageOutputSizes = imageOutputSizes
|
||||
forwardResult.BillingModel = imageBillingModel
|
||||
}
|
||||
return forwardResult, nil
|
||||
@ -3361,15 +3385,17 @@ func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
|
||||
}
|
||||
|
||||
type openaiStreamingResultPassthrough struct {
|
||||
usage *OpenAIUsage
|
||||
firstTokenMs *int
|
||||
imageCount int
|
||||
usage *OpenAIUsage
|
||||
firstTokenMs *int
|
||||
imageCount int
|
||||
imageOutputSizes []string
|
||||
}
|
||||
|
||||
type openaiNonStreamingResultPassthrough struct {
|
||||
*OpenAIUsage
|
||||
usage *OpenAIUsage
|
||||
imageCount int
|
||||
usage *OpenAIUsage
|
||||
imageCount int
|
||||
imageOutputSizes []string
|
||||
}
|
||||
|
||||
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)
|
||||
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() {
|
||||
@ -3696,9 +3727,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
return &openaiNonStreamingResultPassthrough{
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
|
||||
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -3758,9 +3790,10 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
|
||||
return &openaiNonStreamingResultPassthrough{
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
|
||||
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -4182,15 +4215,17 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
|
||||
|
||||
// openaiStreamingResult streaming response result
|
||||
type openaiStreamingResult struct {
|
||||
usage *OpenAIUsage
|
||||
firstTokenMs *int
|
||||
imageCount int
|
||||
usage *OpenAIUsage
|
||||
firstTokenMs *int
|
||||
imageCount int
|
||||
imageOutputSizes []string
|
||||
}
|
||||
|
||||
type openaiNonStreamingResult struct {
|
||||
*OpenAIUsage
|
||||
usage *OpenAIUsage
|
||||
imageCount int
|
||||
usage *OpenAIUsage
|
||||
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) {
|
||||
@ -4303,7 +4338,12 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
|
||||
needModelReplace := originalModel != mappedModel
|
||||
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) {
|
||||
if !sawTerminalEvent {
|
||||
@ -4711,9 +4751,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
|
||||
return &openaiNonStreamingResult{
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
|
||||
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -4775,9 +4816,10 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
|
||||
return &openaiNonStreamingResult{
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
|
||||
OpenAIUsage: usage,
|
||||
usage: usage,
|
||||
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
|
||||
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -5216,6 +5258,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
user := input.User
|
||||
account := input.Account
|
||||
subscription := input.Subscription
|
||||
ApplyOpenAIImageBillingResolution(result)
|
||||
|
||||
// 计算实际的新输入token(减去缓存读取的token)
|
||||
// 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费
|
||||
@ -5325,6 +5368,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
ImageCount: result.ImageCount,
|
||||
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
|
||||
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
|
||||
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
|
||||
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
|
||||
ImageSizeBreakdown: result.ImageSizeBreakdown,
|
||||
}
|
||||
if cost != nil {
|
||||
usageLog.InputCost = cost.InputCost
|
||||
@ -5493,6 +5540,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||||
result *OpenAIForwardResult,
|
||||
multiplier float64,
|
||||
) *CostBreakdown {
|
||||
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
|
||||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
|
||||
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
|
||||
gid := apiKey.Group.ID
|
||||
@ -5501,7 +5549,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||||
Model: billingModel,
|
||||
GroupID: &gid,
|
||||
RequestCount: result.ImageCount,
|
||||
SizeTier: result.ImageSize,
|
||||
SizeTier: sizeTier,
|
||||
RateMultiplier: multiplier,
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
@ -5520,7 +5568,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||||
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 {
|
||||
|
||||
@ -532,54 +532,7 @@ func isOpenAINativeImageOption(name string) bool {
|
||||
}
|
||||
|
||||
func normalizeOpenAIImageSizeTier(size string) string {
|
||||
trimmed := strings.TrimSpace(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"
|
||||
return NormalizeImageBillingTierOrDefault(size)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ForwardImages(
|
||||
@ -704,29 +657,46 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
imageCount := parsed.N
|
||||
var firstTokenMs *int
|
||||
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 streamCount > 0 {
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: streamUsage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: upstreamModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: ttft,
|
||||
ImageCount: streamCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: streamUsage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: upstreamModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: ttft,
|
||||
ImageCount: streamCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
ImageInputSize: parsed.Size,
|
||||
ImageOutputSizes: streamSizes,
|
||||
}, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = streamUsage
|
||||
imageCount = streamCount
|
||||
imageOutputSizes := streamSizes
|
||||
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 {
|
||||
nonStreamUsage, nonStreamCount, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
|
||||
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -734,19 +704,21 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
if nonStreamCount > 0 {
|
||||
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(
|
||||
@ -892,10 +864,10 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
|
||||
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)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
return OpenAIUsage{}, 0, nil, err
|
||||
}
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
contentType := "application/json"
|
||||
@ -907,14 +879,14 @@ func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
|
||||
usage, _ := extractOpenAIUsageFromJSONBytes(body)
|
||||
return usage, extractOpenAIImageCountFromJSONBytes(body), nil
|
||||
return usage, extractOpenAIImageCountFromJSONBytes(body), collectOpenAIResponseImageOutputSizesFromJSONBytes(body), nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
startTime time.Time,
|
||||
) (OpenAIUsage, int, *int, error) {
|
||||
) (OpenAIUsage, int, []string, *int, error) {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if contentType == "" {
|
||||
@ -925,7 +897,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
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{}
|
||||
@ -1010,12 +982,12 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
}
|
||||
if err != nil {
|
||||
flushSSEEvent()
|
||||
return usage, imageCounter.Count(), firstTokenMs, err
|
||||
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, err
|
||||
}
|
||||
}
|
||||
flushSSEEvent()
|
||||
finalizeFallbackBody()
|
||||
return usage, imageCounter.Count(), firstTokenMs, nil
|
||||
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
|
||||
}
|
||||
|
||||
type readEvent struct {
|
||||
@ -1082,11 +1054,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
if !ok {
|
||||
flushSSEEvent()
|
||||
finalizeFallbackBody()
|
||||
return usage, imageCounter.Count(), firstTokenMs, nil
|
||||
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
|
||||
}
|
||||
if ev.err != nil {
|
||||
flushSSEEvent()
|
||||
return usage, imageCounter.Count(), firstTokenMs, ev.err
|
||||
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, ev.err
|
||||
}
|
||||
processLine(ev.line)
|
||||
case <-intervalCh:
|
||||
@ -1095,11 +1067,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
|
||||
continue
|
||||
}
|
||||
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)
|
||||
_ = 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:
|
||||
if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
|
||||
continue
|
||||
|
||||
@ -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) {
|
||||
switch gjson.GetBytes(payload, "type").String() {
|
||||
case "response.created", "response.in_progress", "response.completed":
|
||||
@ -547,10 +563,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
c *gin.Context,
|
||||
responseFormat string,
|
||||
fallbackModel string,
|
||||
) (OpenAIUsage, int, error) {
|
||||
) (OpenAIUsage, int, []string, error) {
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
return OpenAIUsage{}, 0, nil, err
|
||||
}
|
||||
|
||||
var usage OpenAIUsage
|
||||
@ -559,10 +575,10 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
})
|
||||
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
return OpenAIUsage{}, 0, nil, err
|
||||
}
|
||||
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) == "" {
|
||||
firstMeta.Model = strings.TrimSpace(fallbackModel)
|
||||
@ -570,11 +586,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
|
||||
responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
return OpenAIUsage{}, 0, nil, err
|
||||
}
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
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(
|
||||
@ -584,7 +600,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
responseFormat string,
|
||||
streamPrefix string,
|
||||
fallbackModel string,
|
||||
) (OpenAIUsage, int, *int, error) {
|
||||
) (OpenAIUsage, int, []string, *int, error) {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@ -593,7 +609,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
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))
|
||||
@ -603,6 +619,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
|
||||
usage := OpenAIUsage{}
|
||||
imageCount := 0
|
||||
var imageOutputSizes []string
|
||||
var firstTokenMs *int
|
||||
emitted := make(map[string]struct{})
|
||||
pendingResults := make([]openAIResponsesImageResult, 0, 1)
|
||||
@ -713,6 +730,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
|
||||
}
|
||||
imageCount = len(emitted)
|
||||
imageOutputSizes = openAIResponsesImageResultSizes(finalResults)
|
||||
processDataDone = true
|
||||
}
|
||||
}
|
||||
@ -753,6 +771,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
|
||||
}
|
||||
imageCount = len(emitted)
|
||||
imageOutputSizes = openAIResponsesImageResultSizes(pendingResults)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -769,33 +788,33 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
line, err := reader.ReadBytes('\n')
|
||||
done, processErr := processLine(line)
|
||||
if processErr != nil {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
}
|
||||
if done {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
if done, processErr := flushData(); processErr != nil {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
} else if done {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||||
}
|
||||
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 {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
} else if done {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, 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 {
|
||||
@ -861,30 +880,30 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
if done, processErr := flushData(); processErr != nil {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
} else if done {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, 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 done, processErr := flushData(); processErr != nil {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
} 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()))
|
||||
return usage, imageCount, firstTokenMs, ev.err
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, ev.err
|
||||
}
|
||||
done, processErr := processLine(ev.line)
|
||||
if processErr != nil {
|
||||
return usage, imageCount, firstTokenMs, processErr
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, processErr
|
||||
}
|
||||
if done {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
return usage, imageCount, imageOutputSizes, firstTokenMs, nil
|
||||
}
|
||||
case <-intervalCh:
|
||||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||||
@ -892,11 +911,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
continue
|
||||
}
|
||||
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)
|
||||
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:
|
||||
if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
|
||||
continue
|
||||
@ -1019,31 +1038,34 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var (
|
||||
usage OpenAIUsage
|
||||
imageCount int
|
||||
firstTokenMs *int
|
||||
usage OpenAIUsage
|
||||
imageCount int
|
||||
imageOutputSizes []string
|
||||
firstTokenMs *int
|
||||
)
|
||||
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 imageCount > 0 {
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: usage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: requestModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: usage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: requestModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
ImageInputSize: parsed.Size,
|
||||
ImageOutputSizes: imageOutputSizes,
|
||||
}, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} 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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -1052,15 +1074,17 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
imageCount = parsed.N
|
||||
}
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: usage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: requestModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: usage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: requestModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
ImageInputSize: parsed.Size,
|
||||
ImageOutputSizes: imageOutputSizes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -149,9 +149,9 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCusto
|
||||
{size: "2048x1152", wantTier: "2K"},
|
||||
{size: "3840x2160", wantTier: "4K"},
|
||||
{size: "2160x3840", wantTier: "4K"},
|
||||
{size: "1024X768", wantTier: "2K"},
|
||||
{size: "1024X768", wantTier: "1K"},
|
||||
{size: "1280x768", wantTier: "2K"},
|
||||
{size: "2560x1440", wantTier: "2K"},
|
||||
{size: "2560x1440", wantTier: "4K"},
|
||||
{size: "2560x1600", wantTier: "4K"},
|
||||
{size: "auto", wantTier: "2K"},
|
||||
}
|
||||
@ -186,7 +186,7 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_UnknownSizesDoNotBlockPass
|
||||
{size: "2048x1153", wantTier: "2K"},
|
||||
{size: "4096x1024", wantTier: "4K"},
|
||||
{size: "3840x1024", wantTier: "4K"},
|
||||
{size: "512x512", wantTier: "2K"},
|
||||
{size: "512x512", wantTier: "1K"},
|
||||
{size: "invalid", wantTier: "2K"},
|
||||
{size: "999999999999999999999999999x2", wantTier: "2K"},
|
||||
}
|
||||
|
||||
@ -2351,18 +2351,19 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
)
|
||||
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ImageCount: imageCounter.Count(),
|
||||
ServiceTier: extractOpenAIServiceTier(reqBody),
|
||||
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
RequestID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ImageCount: imageCounter.Count(),
|
||||
ImageOutputSizes: imageCounter.Sizes(),
|
||||
ServiceTier: extractOpenAIServiceTier(reqBody),
|
||||
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -2464,6 +2465,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
originalModel string
|
||||
imageBillingModel string
|
||||
imageSizeTier string
|
||||
imageInputSize string
|
||||
payloadBytes int
|
||||
}
|
||||
|
||||
@ -2567,12 +2569,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
if imageIntent {
|
||||
var imageCfgErr error
|
||||
imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(normalized, originalModel)
|
||||
imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(normalized, originalModel)
|
||||
if imageCfgErr != nil {
|
||||
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
|
||||
@ -2621,6 +2627,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
originalModel: originalModel,
|
||||
imageBillingModel: imageBillingModel,
|
||||
imageSizeTier: imageSizeTier,
|
||||
imageInputSize: imageInputSize,
|
||||
payloadBytes: len(normalized),
|
||||
}, nil
|
||||
}
|
||||
@ -2822,7 +2829,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
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 {
|
||||
return nil, errors.New("upstream websocket lease is nil")
|
||||
}
|
||||
@ -3046,6 +3053,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
if imageCount > 0 {
|
||||
result.ImageCount = imageCount
|
||||
result.ImageSize = imageSizeTier
|
||||
result.ImageInputSize = imageInputSize
|
||||
result.ImageOutputSizes = imageCounter.Sizes()
|
||||
result.BillingModel = imageBillingModel
|
||||
}
|
||||
return result, nil
|
||||
@ -3057,6 +3066,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
currentOriginalModel := firstPayload.originalModel
|
||||
currentImageBillingModel := firstPayload.imageBillingModel
|
||||
currentImageSizeTier := firstPayload.imageSizeTier
|
||||
currentImageInputSize := firstPayload.imageInputSize
|
||||
currentPayloadBytes := firstPayload.payloadBytes
|
||||
isStrictAffinityTurn := func(payload []byte) bool {
|
||||
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 {
|
||||
lastTurnClean = false
|
||||
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
|
||||
@ -3658,6 +3668,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
currentOriginalModel = nextPayload.originalModel
|
||||
currentImageBillingModel = nextPayload.imageBillingModel
|
||||
currentImageSizeTier = nextPayload.imageSizeTier
|
||||
currentImageInputSize = nextPayload.imageInputSize
|
||||
currentPayloadBytes = nextPayload.payloadBytes
|
||||
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
|
||||
if !storeDisabled {
|
||||
|
||||
@ -162,9 +162,13 @@ type UsageLog struct {
|
||||
CacheTTLOverridden bool
|
||||
|
||||
// 图片生成字段
|
||||
ImageCount int
|
||||
ImageSize *string
|
||||
MediaType *string
|
||||
ImageCount int
|
||||
ImageSize *string
|
||||
ImageInputSize *string
|
||||
ImageOutputSize *string
|
||||
ImageSizeSource *string
|
||||
ImageSizeBreakdown map[string]int
|
||||
MediaType *string
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
|
||||
51
backend/migrations/136_usage_log_image_size_metadata.sql
Normal file
51
backend/migrations/136_usage_log_image_size_metadata.sql
Normal 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 $$;
|
||||
@ -86,19 +86,19 @@
|
||||
</template>
|
||||
|
||||
<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)">
|
||||
{{ getBillingModeLabel(row.billing_mode, t) }}
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
|
||||
{{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<!-- Token 请求 -->
|
||||
<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>
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-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>
|
||||
<!-- Per-request / image billing: show unit price -->
|
||||
<template v-else-if="tooltipData?.billing_mode === BILLING_MODE_IMAGE">
|
||||
<template v-if="tooltipData && isImageUsage(tooltipData)">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<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 class="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<span class="text-gray-400">{{ t('usage.unitPrice') }}</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 { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
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 */
|
||||
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
|
||||
}
|
||||
|
||||
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 EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
@ -22,6 +22,26 @@ const messages: Record<string, string> = {
|
||||
'usage.original': 'Original',
|
||||
'usage.userBilled': 'User 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 () => {
|
||||
@ -40,12 +60,42 @@ const DataTableStub = {
|
||||
<div>
|
||||
<div v-for="row in data" :key="row.request_id">
|
||||
<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" />
|
||||
</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', () => {
|
||||
beforeEach(() => {
|
||||
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()
|
||||
|
||||
const text = wrapper.text()
|
||||
@ -147,4 +198,126 @@ describe('admin UsageTable tooltip', () => {
|
||||
expect(text).toContain('claude-sonnet-4')
|
||||
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)')
|
||||
})
|
||||
})
|
||||
|
||||
@ -855,6 +855,19 @@ export default {
|
||||
imageUnitPrice: 'Per-image price',
|
||||
imageTotalPrice: 'Image total price',
|
||||
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',
|
||||
cacheWrite: 'Write',
|
||||
serviceTier: 'Service tier',
|
||||
|
||||
@ -859,6 +859,19 @@ export default {
|
||||
imageUnitPrice: '单张价格',
|
||||
imageTotalPrice: '图片总价',
|
||||
imageCount: '图片张数',
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageSizeSource: '尺寸来源',
|
||||
imageSizeBreakdown: '尺寸明细',
|
||||
imageSizeSourceOutput: '上游输出',
|
||||
imageSizeSourceInput: '请求输入',
|
||||
imageSizeSourceDefault: '默认计费档位',
|
||||
imageSizeSourceLegacy: '历史记录',
|
||||
imageSizeSourceMissing: '未记录',
|
||||
imageSizeNotRecorded: '未记录',
|
||||
imageSizeLegacyUnstandardized: '历史非标准',
|
||||
imageSizeUnknown: '未知',
|
||||
cacheRead: '读取',
|
||||
cacheWrite: '写入',
|
||||
serviceTier: '服务档位',
|
||||
|
||||
@ -1154,6 +1154,8 @@ export interface CodexSessionImportResult {
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2'
|
||||
export type ImageSizeSource = 'output' | 'input' | 'default' | 'legacy'
|
||||
export type ImageSizeBreakdown = Record<string, number>
|
||||
|
||||
export interface UsageLog {
|
||||
id: number
|
||||
@ -1195,6 +1197,10 @@ export interface UsageLog {
|
||||
// 图片生成字段
|
||||
image_count: number
|
||||
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: string | null
|
||||
|
||||
56
frontend/src/utils/imageUsage.ts
Normal file
56
frontend/src/utils/imageUsage.ts
Normal 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(', ')
|
||||
}
|
||||
@ -191,14 +191,14 @@
|
||||
|
||||
<template #cell-billing_mode="{ row }">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
|
||||
:class="getBillingModeBadgeClass(row.billing_mode)">
|
||||
{{ getBillingModeLabel(row.billing_mode, t) }}
|
||||
:class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
|
||||
{{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<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
|
||||
class="h-4 w-4 text-indigo-500"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<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="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
|
||||
<span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
|
||||
</div>
|
||||
<!-- Token 请求 -->
|
||||
<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="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="!tooltipData?.billing_mode || 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>
|
||||
<!-- Per-request / image billing: show unit price -->
|
||||
<template v-else-if="tooltipData?.billing_mode === 'image'">
|
||||
<!-- Per-image billing: show image metadata and unit price -->
|
||||
<template v-if="tooltipData && isImageUsage(tooltipData)">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<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 class="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<span class="text-gray-400">{{ t('usage.unitPrice') }}</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 { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
|
||||
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 appStore = useAppStore()
|
||||
@ -646,6 +678,17 @@ const imageUnitPrice = (row: UsageLog | null): number => {
|
||||
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 => {
|
||||
return ua
|
||||
}
|
||||
@ -877,7 +920,7 @@ const exportToCSV = async () => {
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.inbound_endpoint || '',
|
||||
getRequestTypeExportText(log),
|
||||
getBillingModeLabel(log.billing_mode, t),
|
||||
getBillingModeLabel(getDisplayBillingMode(log), t),
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
|
||||
@ -41,6 +41,26 @@ const messages: Record<string, string> = {
|
||||
'usage.duration': 'Duration',
|
||||
'usage.time': 'Time',
|
||||
'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', () => ({
|
||||
@ -69,7 +89,19 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
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', () => {
|
||||
@ -146,6 +178,7 @@ describe('user UsageView tooltip', () => {
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
@ -244,6 +277,7 @@ describe('user UsageView tooltip', () => {
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
DataTable: DataTableStub,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
@ -274,4 +308,233 @@ describe('user UsageView tooltip', () => {
|
||||
window.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user