Fix image billing size normalization

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

View File

@ -1318,6 +1318,10 @@ var (
{Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45},
{Name: "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]},
},
},
}

View File

@ -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

View File

@ -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()

View File

@ -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),

View File

@ -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(", ")

View File

@ -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()

View File

@ -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))

View File

@ -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) {

View File

@ -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)
}

View File

@ -216,6 +216,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.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=

View File

@ -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,

View File

@ -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
}

View File

@ -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"`

View File

@ -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
}

View File

@ -44,6 +44,33 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false)
requireColumn(t, tx, "usage_logs", "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

View File

@ -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

View File

@ -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{},

View File

@ -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",

View File

@ -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 判断模型是否为图片生成模型

View File

@ -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)

View File

@ -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{}

View File

@ -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{}

View File

@ -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),

View File

@ -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 ""
}

View File

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

View File

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

View File

@ -170,7 +170,21 @@ func cloneRequestMapForImageIntent(body []byte) map[string]any {
return out
}
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 {

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -72,6 +72,22 @@ func mergeOpenAIResponsesImageMeta(dst *openAIResponsesImageResult, src openAIRe
}
}
func openAIResponsesImageResultSizes(results []openAIResponsesImageResult) []string {
if len(results) == 0 {
return nil
}
sizes := make([]string, 0, len(results))
for _, result := range results {
if size := strings.TrimSpace(result.Size); size != "" {
sizes = append(sizes, size)
}
}
if len(sizes) == 0 {
return nil
}
return sizes
}
func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) {
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
}

View File

@ -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"},
}

View File

@ -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 {

View File

@ -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

View File

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

View File

@ -86,19 +86,19 @@
</template>
<template #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'

View File

@ -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)')
})
})

View File

@ -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',

View File

@ -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: '服务档位',

View File

@ -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

View File

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

View File

@ -191,14 +191,14 @@
<template #cell-billing_mode="{ row }">
<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,

View File

@ -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')
})
})