Merge pull request #5396 from Brisbanehuang/codex/upstream-response-model-audit

feat(usage): 增加上游响应模型审计
This commit is contained in:
Wesley Liddick
2026-08-07 23:00:46 +08:00
committed by GitHub
76 changed files with 2401 additions and 532 deletions
+16 -14
View File
@@ -1621,6 +1621,8 @@ var (
{Name: "model", Type: field.TypeString, Size: 100},
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200},
{Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true},
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
@@ -1671,31 +1673,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1704,32 +1706,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[42]},
},
{
Name: "usagelog_model",
@@ -1749,17 +1751,17 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]},
},
{
Name: "usagelog_group_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]},
},
},
}
+147 -1
View File
@@ -43356,6 +43356,8 @@ type UsageLogMutation struct {
model *string
requested_model *string
upstream_model *string
upstream_response_model *string
upstream_model_mismatch *bool
channel_id *int64
addchannel_id *int64
model_mapping_chain *string
@@ -43805,6 +43807,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() {
delete(m.clearedFields, usagelog.FieldUpstreamModel)
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (m *UsageLogMutation) SetUpstreamResponseModel(s string) {
m.upstream_response_model = &s
}
// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation.
func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) {
v := m.upstream_response_model
if v == nil {
return
}
return *v, true
}
// OldUpstreamResponseModel returns the old "upstream_response_model" 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) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err)
}
return oldValue.UpstreamResponseModel, nil
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (m *UsageLogMutation) ClearUpstreamResponseModel() {
m.upstream_response_model = nil
m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{}
}
// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation.
func (m *UsageLogMutation) UpstreamResponseModelCleared() bool {
_, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel]
return ok
}
// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field.
func (m *UsageLogMutation) ResetUpstreamResponseModel() {
m.upstream_response_model = nil
delete(m.clearedFields, usagelog.FieldUpstreamResponseModel)
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) {
m.upstream_model_mismatch = &b
}
// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation.
func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) {
v := m.upstream_model_mismatch
if v == nil {
return
}
return *v, true
}
// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" 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) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err)
}
return oldValue.UpstreamModelMismatch, nil
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (m *UsageLogMutation) ClearUpstreamModelMismatch() {
m.upstream_model_mismatch = nil
m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{}
}
// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation.
func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool {
_, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch]
return ok
}
// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field.
func (m *UsageLogMutation) ResetUpstreamModelMismatch() {
m.upstream_model_mismatch = nil
delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch)
}
// SetChannelID sets the "channel_id" field.
func (m *UsageLogMutation) SetChannelID(i int64) {
m.channel_id = &i
@@ -46001,7 +46101,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, 45)
fields := make([]string, 0, 47)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -46023,6 +46123,12 @@ func (m *UsageLogMutation) Fields() []string {
if m.upstream_model != nil {
fields = append(fields, usagelog.FieldUpstreamModel)
}
if m.upstream_response_model != nil {
fields = append(fields, usagelog.FieldUpstreamResponseModel)
}
if m.upstream_model_mismatch != nil {
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
}
if m.channel_id != nil {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -46159,6 +46265,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.RequestedModel()
case usagelog.FieldUpstreamModel:
return m.UpstreamModel()
case usagelog.FieldUpstreamResponseModel:
return m.UpstreamResponseModel()
case usagelog.FieldUpstreamModelMismatch:
return m.UpstreamModelMismatch()
case usagelog.FieldChannelID:
return m.ChannelID()
case usagelog.FieldModelMappingChain:
@@ -46258,6 +46368,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldRequestedModel(ctx)
case usagelog.FieldUpstreamModel:
return m.OldUpstreamModel(ctx)
case usagelog.FieldUpstreamResponseModel:
return m.OldUpstreamResponseModel(ctx)
case usagelog.FieldUpstreamModelMismatch:
return m.OldUpstreamModelMismatch(ctx)
case usagelog.FieldChannelID:
return m.OldChannelID(ctx)
case usagelog.FieldModelMappingChain:
@@ -46392,6 +46506,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetUpstreamModel(v)
return nil
case usagelog.FieldUpstreamResponseModel:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUpstreamResponseModel(v)
return nil
case usagelog.FieldUpstreamModelMismatch:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUpstreamModelMismatch(v)
return nil
case usagelog.FieldChannelID:
v, ok := value.(int64)
if !ok {
@@ -46949,6 +47077,12 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldUpstreamModel) {
fields = append(fields, usagelog.FieldUpstreamModel)
}
if m.FieldCleared(usagelog.FieldUpstreamResponseModel) {
fields = append(fields, usagelog.FieldUpstreamResponseModel)
}
if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) {
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
}
if m.FieldCleared(usagelog.FieldChannelID) {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -47023,6 +47157,12 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldUpstreamModel:
m.ClearUpstreamModel()
return nil
case usagelog.FieldUpstreamResponseModel:
m.ClearUpstreamResponseModel()
return nil
case usagelog.FieldUpstreamModelMismatch:
m.ClearUpstreamModelMismatch()
return nil
case usagelog.FieldChannelID:
m.ClearChannelID()
return nil
@@ -47106,6 +47246,12 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldUpstreamModel:
m.ResetUpstreamModel()
return nil
case usagelog.FieldUpstreamResponseModel:
m.ResetUpstreamResponseModel()
return nil
case usagelog.FieldUpstreamModelMismatch:
m.ResetUpstreamModelMismatch()
return nil
case usagelog.FieldChannelID:
m.ResetChannelID()
return nil
+34 -30
View File
@@ -1982,124 +1982,128 @@ func init() {
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
// usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field.
usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor()
// usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error)
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
usagelogDescModelMappingChain := usagelogFields[10].Descriptor()
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
usagelogDescBillingTier := usagelogFields[9].Descriptor()
usagelogDescBillingTier := usagelogFields[11].Descriptor()
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
usagelogDescBillingMode := usagelogFields[10].Descriptor()
usagelogDescBillingMode := usagelogFields[12].Descriptor()
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
usagelogDescInputTokens := usagelogFields[13].Descriptor()
usagelogDescInputTokens := usagelogFields[15].Descriptor()
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
usagelogDescOutputTokens := usagelogFields[14].Descriptor()
usagelogDescOutputTokens := usagelogFields[16].Descriptor()
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor()
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
usagelogDescCacheReadTokens := usagelogFields[18].Descriptor()
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor()
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor()
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
// usagelogDescInputCost is the schema descriptor for input_cost field.
usagelogDescInputCost := usagelogFields[19].Descriptor()
usagelogDescInputCost := usagelogFields[21].Descriptor()
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
// usagelogDescOutputCost is the schema descriptor for output_cost field.
usagelogDescOutputCost := usagelogFields[20].Descriptor()
usagelogDescOutputCost := usagelogFields[22].Descriptor()
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
usagelogDescCacheCreationCost := usagelogFields[23].Descriptor()
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
usagelogDescCacheReadCost := usagelogFields[24].Descriptor()
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
// usagelogDescTotalCost is the schema descriptor for total_cost field.
usagelogDescTotalCost := usagelogFields[23].Descriptor()
usagelogDescTotalCost := usagelogFields[25].Descriptor()
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
// usagelogDescActualCost is the schema descriptor for actual_cost field.
usagelogDescActualCost := usagelogFields[24].Descriptor()
usagelogDescActualCost := usagelogFields[26].Descriptor()
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
usagelogDescRateMultiplier := usagelogFields[27].Descriptor()
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor()
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
// usagelogDescBillingType is the schema descriptor for billing_type field.
usagelogDescBillingType := usagelogFields[28].Descriptor()
usagelogDescBillingType := usagelogFields[30].Descriptor()
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
// usagelogDescStream is the schema descriptor for stream field.
usagelogDescStream := usagelogFields[29].Descriptor()
usagelogDescStream := usagelogFields[31].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescUserAgent is the schema descriptor for user_agent field.
usagelogDescUserAgent := usagelogFields[32].Descriptor()
usagelogDescUserAgent := usagelogFields[34].Descriptor()
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
// usagelogDescIPAddress is the schema descriptor for ip_address field.
usagelogDescIPAddress := usagelogFields[33].Descriptor()
usagelogDescIPAddress := usagelogFields[35].Descriptor()
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
// usagelogDescImageCount is the schema descriptor for image_count field.
usagelogDescImageCount := usagelogFields[34].Descriptor()
usagelogDescImageCount := usagelogFields[36].Descriptor()
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
// usagelogDescImageSize is the schema descriptor for image_size field.
usagelogDescImageSize := usagelogFields[35].Descriptor()
usagelogDescImageSize := usagelogFields[37].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[36].Descriptor()
usagelogDescImageInputSize := usagelogFields[38].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[37].Descriptor()
usagelogDescImageOutputSize := usagelogFields[39].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[38].Descriptor()
usagelogDescImageSizeSource := usagelogFields[40].Descriptor()
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
// usagelogDescVideoCount is the schema descriptor for video_count field.
usagelogDescVideoCount := usagelogFields[40].Descriptor()
usagelogDescVideoCount := usagelogFields[42].Descriptor()
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
usagelogDescVideoResolution := usagelogFields[43].Descriptor()
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
usagelogDescCacheTTLOverridden := usagelogFields[45].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[44].Descriptor()
usagelogDescCreatedAt := usagelogFields[46].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()
+11
View File
@@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field {
MaxLen(100).
Optional().
Nillable(),
// UpstreamResponseModel stores the model name declared by the upstream
// response before any protocol conversion or client-facing rewrite.
field.String("upstream_response_model").
MaxLen(200).
Optional().
Nillable(),
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
// not declare a model (or predates this field); false/true means observed.
field.Bool("upstream_model_mismatch").
Optional().
Nillable(),
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
+30 -2
View File
@@ -37,6 +37,10 @@ type UsageLog struct {
RequestedModel *string `json:"requested_model,omitempty"`
// UpstreamModel holds the value of the "upstream_model" field.
UpstreamModel *string `json:"upstream_model,omitempty"`
// UpstreamResponseModel holds the value of the "upstream_response_model" field.
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
// UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field.
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
// 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
// 模型映射链
@@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case usagelog.FieldImageSizeBreakdown:
values[i] = new([]byte)
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, 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, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
values[i] = new(sql.NullInt64)
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.UpstreamModel = new(string)
*_m.UpstreamModel = value.String
}
case usagelog.FieldUpstreamResponseModel:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i])
} else if value.Valid {
_m.UpstreamResponseModel = new(string)
*_m.UpstreamResponseModel = value.String
}
case usagelog.FieldUpstreamModelMismatch:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i])
} else if value.Valid {
_m.UpstreamModelMismatch = new(bool)
*_m.UpstreamModelMismatch = value.Bool
}
case usagelog.FieldChannelID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
@@ -606,6 +624,16 @@ func (_m *UsageLog) String() string {
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamResponseModel; v != nil {
builder.WriteString("upstream_response_model=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamModelMismatch; v != nil {
builder.WriteString("upstream_model_mismatch=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.ChannelID; v != nil {
builder.WriteString("channel_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
+18
View File
@@ -28,6 +28,10 @@ const (
FieldRequestedModel = "requested_model"
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
FieldUpstreamModel = "upstream_model"
// FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database.
FieldUpstreamResponseModel = "upstream_response_model"
// FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database.
FieldUpstreamModelMismatch = "upstream_model_mismatch"
// FieldChannelID holds the string denoting the channel_id field in the database.
FieldChannelID = "channel_id"
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
@@ -163,6 +167,8 @@ var Columns = []string{
FieldModel,
FieldRequestedModel,
FieldUpstreamModel,
FieldUpstreamResponseModel,
FieldUpstreamModelMismatch,
FieldChannelID,
FieldModelMappingChain,
FieldBillingTier,
@@ -222,6 +228,8 @@ var (
RequestedModelValidator func(string) error
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
UpstreamModelValidator func(string) error
// UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
UpstreamResponseModelValidator func(string) error
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
ModelMappingChainValidator func(string) error
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
@@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
}
// ByUpstreamResponseModel orders the results by the upstream_response_model field.
func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc()
}
// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field.
func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc()
}
// ByChannelID orders the results by the channel_id field.
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
+105
View File
@@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
}
// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ.
func UpstreamResponseModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ.
func UpstreamModelMismatch(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
func ChannelID(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
@@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
}
// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field.
func UpstreamResponseModelIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field.
func UpstreamResponseModelGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field.
func UpstreamResponseModelLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field.
func UpstreamResponseModelContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch))
}
// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch))
}
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
func ChannelIDEQ(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
+161
View File
@@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
return _c
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate {
_c.mutation.SetUpstreamResponseModel(v)
return _c
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate {
if v != nil {
_c.SetUpstreamResponseModel(*v)
}
return _c
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate {
_c.mutation.SetUpstreamModelMismatch(v)
return _c
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate {
if v != nil {
_c.SetUpstreamModelMismatch(*v)
}
return _c
}
// SetChannelID sets the "channel_id" field.
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
_c.mutation.SetChannelID(v)
@@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _c.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _c.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
_node.UpstreamModel = &value
}
if value, ok := _c.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
_node.UpstreamResponseModel = &value
}
if value, ok := _c.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
_node.UpstreamModelMismatch = &value
}
if value, ok := _c.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
_node.ChannelID = &value
@@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
return u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamResponseModel, v)
return u
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamResponseModel)
return u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamResponseModel)
return u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamModelMismatch, v)
return u
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamModelMismatch)
return u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamModelMismatch)
return u
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
u.Set(usagelog.FieldChannelID, v)
@@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
+114
View File
@@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
_u.mutation.ResetChannelID()
@@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
_u.mutation.ResetChannelID()
@@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
return startTime, endTime
}
func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return nil, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return nil, err
}
return &value, nil
}
// GetStats handles getting dashboard statistics
// GET /api/v1/admin/dashboard/stats
func (h *DashboardHandler) GetStats(c *gin.Context) {
@@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get usage trend")
return
@@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType)
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get model statistics")
return
@@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get group statistics")
return
@@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct {
trendStream *bool
modelRequestType *int16
modelStream *bool
trendMismatch *bool
modelMismatch *bool
groupMismatch *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
granularity string,
filters usagestats.UsageLogFilters,
) ([]usagestats.TrendDataPoint, error) {
s.trendRequestType = filters.RequestType
s.trendStream = filters.Stream
s.trendMismatch = filters.UpstreamModelMismatch
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
ctx context.Context,
startTime, endTime time.Time,
@@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
source string,
) ([]usagestats.ModelStat, error) {
s.modelRequestType = filters.RequestType
s.modelStream = filters.Stream
s.modelMismatch = filters.UpstreamModelMismatch
return []usagestats.ModelStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
) ([]usagestats.GroupStat, error) {
s.groupMismatch = filters.UpstreamModelMismatch
return []usagestats.GroupStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
ctx context.Context,
startTime, endTime time.Time,
@@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
router := gin.New()
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
router.GET("/admin/dashboard/models", handler.GetModelStats)
router.GET("/admin/dashboard/groups", handler.GetGroupStats)
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
return router
}
@@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
}
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=true",
"/admin/dashboard/models?upstream_model_mismatch=true",
"/admin/dashboard/groups?upstream_model_mismatch=true",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, path)
}
require.NotNil(t, repo.trendMismatch)
require.True(t, *repo.trendMismatch)
require.NotNil(t, repo.modelMismatch)
require.True(t, *repo.modelMismatch)
require.NotNil(t, repo.groupMismatch)
require.True(t, *repo.groupMismatch)
}
func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=invalid",
"/admin/dashboard/models?upstream_model_mismatch=invalid",
"/admin/dashboard/groups?upstream_model_mismatch=invalid",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, path)
}
}
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
repo := &dashboardUsageRepoCapture{
@@ -18,30 +18,32 @@ var (
)
type dashboardTrendCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardModelGroupCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardEntityTrendCacheKey struct {
@@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.TrendDataPoint, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.ModelStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource)
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
}, modelSource)
})
if err != nil {
return nil, hit, err
@@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.GroupStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct {
}
type dashboardSnapshotV2Filters struct {
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
UpstreamModelMismatch *bool
}
type dashboardSnapshotV2CacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
}
func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
@@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
}
keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
})
cacheKey := string(keyRaw)
@@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get usage trend")
@@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get model statistics")
@@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get group statistics")
@@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
filters.BillingType = &bt
}
if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" {
value, err := strconv.ParseBool(mismatchStr)
if err != nil {
return nil, err
}
filters.UpstreamModelMismatch = &value
}
return filters, nil
}
+48 -26
View File
@@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) {
billingType = &bt
}
var upstreamModelMismatch *bool
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
upstreamModelMismatch = &value
}
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
@@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) {
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestID: requestID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: startTime,
EndTime: endTime,
ExactTotal: exactTotal,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestID: requestID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
UpstreamModelMismatch: upstreamModelMismatch,
StartTime: startTime,
EndTime: endTime,
ExactTotal: exactTotal,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
@@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) {
billingType = &bt
}
var upstreamModelMismatch *bool
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
upstreamModelMismatch = &value
}
// Parse date range
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
@@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) {
// Build filters and call GetStatsWithFilters
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: &startTime,
EndTime: &endTime,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
UpstreamModelMismatch: upstreamModelMismatch,
StartTime: &startTime,
EndTime: &endTime,
}
var stats *usagestats.UsageStats
@@ -11,17 +11,18 @@ import (
var usageStatsCache = newSnapshotCache(30 * time.Second)
type usageStatsCacheKeyData struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
@@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
end = filters.EndTime.UTC().Format(time.RFC3339)
}
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
})
}
+2
View File
@@ -705,6 +705,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
return &AdminUsageLog{
UsageLog: usageLog,
UpstreamModel: l.UpstreamModel,
UpstreamResponseModel: l.UpstreamResponseModel,
UpstreamModelMismatch: l.UpstreamModelMismatch,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
BillingTier: l.BillingTier,
@@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
t.Parallel()
upstreamModel := "claude-sonnet-4-20250514"
upstreamResponseModel := "claude-sonnet-4-20250513"
upstreamModelMismatch := true
log := &service.UsageLog{
RequestID: "req_4",
Model: upstreamModel,
RequestedModel: "claude-sonnet-4",
UpstreamModel: &upstreamModel,
RequestID: "req_4",
Model: upstreamModel,
RequestedModel: "claude-sonnet-4",
UpstreamModel: &upstreamModel,
UpstreamResponseModel: &upstreamResponseModel,
UpstreamModelMismatch: &upstreamModelMismatch,
}
userDTO := UsageLogFromService(log)
@@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
require.NotContains(t, string(userJSON), "upstream_model")
require.NotContains(t, string(userJSON), "upstream_response_model")
require.NotContains(t, string(userJSON), "upstream_model_mismatch")
adminJSON, err := json.Marshal(adminDTO)
require.NoError(t, err)
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`)
require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`)
}
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
+4
View File
@@ -555,6 +555,10 @@ type AdminUsageLog struct {
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Omitted when no mapping was applied (requested model was used as-is).
UpstreamModel *string `json:"upstream_model,omitempty"`
// UpstreamResponseModel is the raw model declared by the upstream response.
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
// UpstreamModelMismatch is nil when the upstream did not declare a model.
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
// ChannelID 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
@@ -224,6 +224,8 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T)
require.NotContains(t, body, "account_rate_multiplier")
require.NotContains(t, body, "account_stats_cost")
require.NotContains(t, body, "upstream_model")
require.NotContains(t, body, "upstream_response_model")
require.NotContains(t, body, "upstream_model_mismatch")
require.NotContains(t, body, "billing_tier")
require.NotContains(t, body, "channel_id")
require.NotContains(t, body, `"account":`)
@@ -274,13 +274,14 @@ type UsageLogFilters struct {
RequestID string
Model string
// ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics.
ModelFilterSource string
RequestType *int16
Stream *bool
BillingType *int8
BillingMode string
StartTime *time.Time
EndTime *time.Time
ModelFilterSource string
RequestType *int16
Stream *bool
BillingType *int8
BillingMode string
UpstreamModelMismatch *bool
StartTime *time.Time
EndTime *time.Time
// ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging.
ExactTotal bool
}
@@ -57,6 +57,8 @@ const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_de
const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key"
const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index_notx.sql"
const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip"
const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql"
const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at"
type migrationChecksumCompatibilityRule struct {
fileChecksum string
@@ -283,6 +285,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio
return dropInvalidIndexIfPresent(ctx, db, schedulerOutboxPendingDedupKeyIndex)
case latestAPIKeyIPIndexMigration:
return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex)
case usageLogsUpstreamModelMismatchIndexMigration:
return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex)
default:
return nil
}
@@ -155,6 +155,42 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_api_key_latest_ip
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyMigrationsFS_NonTransactionalMigration_UsageModelMismatchIndexDropsInvalidIndexBeforeRetry(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
prepareMigrationsBootstrapExpectations(mock)
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs(usageLogsUpstreamModelMismatchIndexMigration).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT EXISTS \\(").
WithArgs(usageLogsUpstreamModelMismatchIndex).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
WithArgs(usageLogsUpstreamModelMismatchIndexMigration, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
WithArgs(migrationsAdvisoryLockID).
WillReturnResult(sqlmock.NewResult(0, 1))
fsys := fstest.MapFS{
usageLogsUpstreamModelMismatchIndexMigration: &fstest.MapFile{Data: []byte(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at
ON usage_logs (created_at DESC, id DESC)
WHERE upstream_model_mismatch IS TRUE;
`)},
}
err = applyMigrationsFS(context.Background(), db, fsys)
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
@@ -76,6 +76,24 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false)
requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true)
requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true)
requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true)
requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true)
requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex)
var mismatchIndexDef string
require.NoError(t, tx.QueryRowContext(context.Background(), `
SELECT pg_get_indexdef(i.indexrelid)
FROM pg_class idx
JOIN pg_index i ON i.indexrelid = idx.oid
JOIN pg_class tbl ON tbl.oid = i.indrelid
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
WHERE ns.nspname = 'public'
AND tbl.relname = 'usage_logs'
AND idx.relname = $1
`, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef))
require.Contains(t, mismatchIndexDef, "created_at DESC")
require.Contains(t, mismatchIndexDef, "id DESC")
require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)")
requireConstraintDefinitionContains(
t,
tx,
@@ -31,6 +31,8 @@ var usageLogInsertArgTypes = [...]string{
"text", // model
"text", // requested_model
"text", // upstream_model
"text", // upstream_response_model
"boolean", // upstream_model_mismatch
"bigint", // group_id
"bigint", // subscription_id
"integer", // input_tokens
@@ -227,6 +229,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -278,12 +282,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
session_id,
created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11,
$12, $13, $14, $15,
$16, $17, $18, $19,
$20, $21, $22, $23, $24, $25,
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -682,6 +686,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -734,9 +740,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
created_at
) AS (VALUES `)
// Each batch row prepends the synthetic input_index before the 57
// Each batch row prepends the synthetic input_index before the 59
// usage-log column values.
args := make([]any, 0, len(keys)*58)
args := make([]any, 0, len(keys)*60)
argPos := 1
for idx, key := range keys {
if idx > 0 {
@@ -772,6 +778,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -831,6 +839,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -930,6 +940,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -982,7 +994,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
created_at
) AS (VALUES `)
args := make([]any, 0, len(preparedList)*57)
args := make([]any, 0, len(preparedList)*59)
argPos := 1
for idx, prepared := range preparedList {
if idx > 0 {
@@ -1015,6 +1027,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -1074,6 +1088,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -1141,6 +1157,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
model,
requested_model,
upstream_model,
upstream_response_model,
upstream_model_mismatch,
group_id,
subscription_id,
input_tokens,
@@ -1192,12 +1210,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
session_id,
created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11,
$12, $13, $14, $15,
$16, $17, $18, $19,
$20, $21, $22, $23, $24, $25,
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...)
@@ -1244,6 +1262,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
requestedModel = strings.TrimSpace(log.Model)
}
upstreamModel := nullString(log.UpstreamModel)
upstreamResponseModel := nullString(log.UpstreamResponseModel)
upstreamModelMismatch := nullBool(log.UpstreamModelMismatch)
var requestIDArg any
if requestID != "" {
@@ -1263,6 +1283,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
log.Model,
nullString(&requestedModel),
upstreamModel,
upstreamResponseModel,
upstreamModelMismatch,
groupID,
subscriptionID,
log.InputTokens,
@@ -19,7 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, 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, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
@@ -127,6 +127,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
args = append(args, int16(*filters.BillingType))
}
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
if filters.UpstreamModelMismatch != nil {
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
}
if filters.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filters.StartTime)
@@ -157,6 +160,13 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
return logs, page, nil
}
func upstreamModelMismatchCondition(column string, mismatch bool) string {
if mismatch {
return column + " IS TRUE"
}
return column + " IS FALSE"
}
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
if filters.ExactTotal {
return false
@@ -437,6 +447,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
model string
requestedModel sql.NullString
upstreamModel sql.NullString
upstreamResponseModel sql.NullString
upstreamModelMismatch sql.NullBool
groupID sql.NullInt64
subscriptionID sql.NullInt64
inputTokens int
@@ -498,6 +510,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&model,
&requestedModel,
&upstreamModel,
&upstreamResponseModel,
&upstreamModelMismatch,
&groupID,
&subscriptionID,
&inputTokens,
@@ -651,6 +665,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
if upstreamModel.Valid {
log.UpstreamModel = &upstreamModel.String
}
if upstreamResponseModel.Valid {
log.UpstreamResponseModel = &upstreamResponseModel.String
}
if upstreamModelMismatch.Valid {
value := upstreamModelMismatch.Bool
log.UpstreamModelMismatch = &value
}
if channelID.Valid {
value := channelID.Int64
log.ChannelID = &value
@@ -703,6 +724,13 @@ func nullString(v *string) sql.NullString {
return sql.NullString{String: *v, Valid: true}
}
func nullBool(v *bool) sql.NullBool {
if v == nil {
return sql.NullBool{}
}
return sql.NullBool{Bool: *v, Valid: true}
}
func nullStringIntMapJSON(v map[string]int) any {
if len(v) == 0 {
return nil
@@ -48,6 +48,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
log.Model,
log.RequestedModel,
sqlmock.AnyArg(), // upstream_model
sqlmock.AnyArg(), // upstream_response_model
sqlmock.AnyArg(), // upstream_model_mismatch
sqlmock.AnyArg(), // group_id
sqlmock.AnyArg(), // subscription_id
log.InputTokens,
@@ -137,9 +139,11 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
log.RequestID,
log.Model,
log.RequestedModel,
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(), // upstream_model
sqlmock.AnyArg(), // upstream_response_model
sqlmock.AnyArg(), // upstream_model_mismatch
sqlmock.AnyArg(), // group_id
sqlmock.AnyArg(), // subscription_id
log.InputTokens,
log.OutputTokens,
log.CacheCreationTokens,
@@ -211,8 +215,8 @@ func TestBuildUsageLogBestEffortInsertQuery_IncludesRequestedModelColumn(t *test
query, args := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
require.Contains(t, query, "INSERT INTO usage_logs (")
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
require.Len(t, args, len(prepared.args))
require.Equal(t, prepared.args[5], args[5])
}
@@ -273,11 +277,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
})
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36])
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37])
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38])
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39])
breakdownJSON, ok := prepared.args[40].(string)
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[38])
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[39])
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[40])
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[41])
breakdownJSON, ok := prepared.args[42].(string)
require.True(t, ok)
require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON)
}
@@ -809,6 +813,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-image-2",
sql.NullString{Valid: true, String: "gpt-image-2"},
sql.NullString{},
sql.NullString{},
sql.NullBool{},
sql.NullInt64{},
sql.NullInt64{},
0, 0, 0, 0, 0, 0,
@@ -872,6 +878,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-5", // model
sql.NullString{Valid: true, String: "gpt-5"}, // requested_model
sql.NullString{}, // upstream_model
sql.NullString{}, // upstream_response_model
sql.NullBool{}, // upstream_model_mismatch
sql.NullInt64{}, // group_id
sql.NullInt64{}, // subscription_id
1, // input_tokens
@@ -942,6 +950,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-5",
sql.NullString{Valid: true, String: "gpt-5"},
sql.NullString{},
sql.NullString{},
sql.NullBool{},
sql.NullInt64{},
sql.NullInt64{},
1, 2, 3, 4, 5, 6,
@@ -1000,6 +1010,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-5.4",
sql.NullString{Valid: true, String: "gpt-5.4"},
sql.NullString{},
sql.NullString{},
sql.NullBool{},
sql.NullInt64{},
sql.NullInt64{},
1, 2, 3, 4, 5, 6,
@@ -683,6 +683,9 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
args = append(args, int16(*filters.BillingType))
}
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
if filters.UpstreamModelMismatch != nil {
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
}
if filters.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filters.StartTime)
@@ -4,6 +4,7 @@ package repository
import (
"context"
"strings"
"testing"
"time"
@@ -12,6 +13,66 @@ import (
"github.com/stretchr/testify/require"
)
func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := newUsageLogRepositoryWithSQL(client, tx)
user := mustCreateUser(t, client, &service.User{Email: "model-audit@test.com"})
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-model-audit", Name: "model-audit"})
account := mustCreateAccount(t, client, &service.Account{Name: "model-audit-account"})
now := time.Now().UTC()
responseModel := "gpt-5.4"
for _, mismatch := range []bool{true, false} {
mismatchValue := mismatch
_, err := repo.Create(ctx, &service.UsageLog{
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
Model: "gpt-5.5", InputTokens: 1, OutputTokens: 1,
UpstreamResponseModel: &responseModel, UpstreamModelMismatch: &mismatchValue,
CreatedAt: now,
})
require.NoError(t, err)
}
start := now.Add(-time.Hour)
end := now.Add(time.Hour)
trueValue := true
stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{
UserID: user.ID, StartTime: &start, EndTime: &end, UpstreamModelMismatch: &trueValue,
})
require.NoError(t, err)
require.Equal(t, int64(1), stats.TotalRequests)
trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{
UserID: user.ID, UpstreamModelMismatch: &trueValue,
})
require.NoError(t, err)
require.Len(t, trend, 1)
require.Equal(t, int64(1), trend[0].Requests)
_, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off")
require.NoError(t, err)
rows, err := tx.QueryContext(ctx, `
EXPLAIN (COSTS OFF)
SELECT id
FROM usage_logs
WHERE upstream_model_mismatch IS TRUE
ORDER BY created_at DESC, id DESC
LIMIT 100
`)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var planLines []string
for rows.Next() {
var line string
require.NoError(t, rows.Scan(&line))
planLines = append(planLines, line)
}
require.NoError(t, rows.Err())
require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex)
}
func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
@@ -265,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user
// GetUserModelStats 获取指定用户的模型统计
func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "")
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil)
}
// GetUsageTrendWithFilters returns usage trend data with optional filters
func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil)
}
func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
}
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) {
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) {
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) {
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) {
aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
if aggregatedErr == nil && len(aggregated) > 0 {
return aggregated, nil
@@ -326,6 +326,9 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
if upstreamModelMismatch != nil {
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
}
query += " GROUP BY date ORDER BY date ASC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -348,7 +351,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
return results, nil
}
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool {
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool {
if granularity != "day" && granularity != "hour" {
return false
}
@@ -360,7 +363,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID
requestType == nil &&
stream == nil &&
billingType == nil &&
billingMode == ""
billingMode == "" &&
upstreamModelMismatch == nil
}
func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
@@ -425,20 +429,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st
// GetModelStatsWithFilters returns model statistics with optional filters
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "")
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil)
}
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
// source: requested | upstream | mapping.
func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "")
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil)
}
func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode)
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch)
}
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) {
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) {
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
if accountID > 0 && userID == 0 && apiKeyID == 0 {
@@ -490,6 +494,9 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
if upstreamModelMismatch != nil {
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
}
query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -514,14 +521,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
// GetGroupStatsWithFilters returns group usage statistics with optional filters
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "")
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil)
}
func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
}
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) {
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) {
query := `
SELECT
COALESCE(ul.group_id, 0) as group_id,
@@ -564,6 +571,9 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
if upstreamModelMismatch != nil {
query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch)
}
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog {
// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id
// is the penultimate arg (created_at is always last).
func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) {
require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id")
require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id")
sessionID := "sess-persisted-123"
prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID))
@@ -28,6 +28,7 @@ import (
// ├─ 成功 → 正常返回
// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
// 上游透传账号直接转发,不走 OAuth token 刷新
if account.Type == AccountTypeUpstream {
return s.ForwardUpstream(ctx, c, account, body)
@@ -448,14 +449,16 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
}
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: billingModel,
Stream: claudeReq.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: billingModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: claudeReq.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
}, nil
}
@@ -168,6 +168,7 @@ func (s *AntigravityGatewayService) forwardAntigravityCompat(
account *Account,
request antigravityCompatRequest,
) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
call, err := s.prepareAntigravityCompatCall(ctx, c, account, request)
if err != nil {
return nil, err
@@ -324,15 +325,17 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse(
}
return &ForwardResult{
RequestID: requestID,
Usage: *streamResult.usage,
Model: call.request.originalModel,
UpstreamModel: call.billingModel,
Stream: call.request.clientStream,
Duration: time.Since(call.request.startTime),
FirstTokenMs: streamResult.firstTokenMs,
ReasoningEffort: call.request.reasoningEffort,
ClientDisconnect: streamResult.clientDisconnect,
RequestID: requestID,
Usage: *streamResult.usage,
Model: call.request.originalModel,
UpstreamModel: call.billingModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: call.request.clientStream,
Duration: time.Since(call.request.startTime),
FirstTokenMs: streamResult.firstTokenMs,
ReasoningEffort: call.request.reasoningEffort,
ClientDisconnect: streamResult.clientDisconnect,
}, nil
}
@@ -477,7 +480,7 @@ func (s *AntigravityGatewayService) handleChatCompletionsNonStreamingFromAntigra
startTime time.Time,
originalModel string,
) (*antigravityStreamResult, error) {
claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel)
claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel)
if err != nil {
return nil, s.mapAntigravityCompatCollectionError(c, err)
}
@@ -496,7 +499,7 @@ func (s *AntigravityGatewayService) handleResponsesNonStreamingFromAntigravity(
startTime time.Time,
originalModel string,
) (*antigravityStreamResult, error) {
claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel)
claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel)
if err != nil {
return nil, s.mapAntigravityCompatCollectionError(c, err)
}
@@ -301,6 +301,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream(
return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix)
}
resetAntigravityCompatTimer(timeoutTimer, timeout)
s.observeAntigravityGeminiSSELine(c, event.line)
session.consume(event.line)
case <-timeoutCh:
@@ -42,6 +42,7 @@ func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOp
}
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
startTime := time.Now()
forwardOpts := forwardGeminiOptions{}
for _, apply := range options {
@@ -203,6 +204,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
if err == nil && fallbackResp.StatusCode < 400 {
_ = resp.Body.Close()
resp = fallbackResp
billingModel = fallbackModel
} else if fallbackResp != nil {
_ = fallbackResp.Body.Close()
}
@@ -431,17 +433,19 @@ handleSuccess:
}
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: billingModel,
Stream: stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: billingModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil
}
@@ -22,6 +22,26 @@ type antigravityStreamResult struct {
clientDisconnect bool // 客户端是否在流式传输过程中断开
}
func (s *AntigravityGatewayService) observeAntigravityGeminiSSELine(c *gin.Context, line string) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "data:") {
return
}
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if payload == "" || payload == "[DONE]" {
return
}
raw := []byte(payload)
if inner, err := s.unwrapV1InternalResponse(raw); err == nil && len(inner) > 0 {
raw = inner
}
observer.ObserveGemini(raw)
}
// antigravityClientWriter 封装流式响应的客户端写入,自动检测断开并标记。
// 断开后所有写入操作变为 no-op,调用方通过 Disconnected() 判断是否继续 drain 上游。
type antigravityClientWriter struct {
@@ -95,6 +115,9 @@ func handleStreamReadError(err error, clientDisconnected bool, prefix string) (d
}
func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) {
if upstreamResponseModelObserverFromContext(c) == nil {
beginUpstreamResponseModelObservation(c)
}
c.Status(resp.StatusCode)
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
@@ -220,6 +243,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
lastDataAt = time.Now()
line := ev.line
s.observeAntigravityGeminiSSELine(c, line)
trimmed := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(trimmed, "data:") {
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
@@ -298,6 +322,9 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
// handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端
// Gemini 流式响应是增量的,需要累积所有 chunk 的内容
func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) {
if upstreamResponseModelObserverFromContext(c) == nil {
beginUpstreamResponseModelObservation(c)
}
scanner := bufio.NewScanner(resp.Body)
maxLineSize := defaultMaxLineSize
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
@@ -377,6 +404,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
}
line := ev.line
s.observeAntigravityGeminiSSELine(c, line)
trimmed := strings.TrimRight(line, "\r\n")
if !strings.HasPrefix(trimmed, "data:") {
@@ -765,7 +793,10 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
// collectClaudeStreamResponse 收集上游流式响应,转换为 Claude 非流式格式返回
// 用于处理客户端非流式请求但上游只支持流式的情况
func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) {
func (s *AntigravityGatewayService) collectClaudeStreamResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) {
if upstreamResponseModelObserverFromContext(c) == nil {
beginUpstreamResponseModelObservation(c)
}
scanner := bufio.NewScanner(resp.Body)
maxLineSize := defaultMaxLineSize
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
@@ -860,6 +891,7 @@ func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Respo
if parseErr != nil {
continue
}
upstreamResponseModelObserverFromContext(c).ObserveGemini(inner)
var parsed map[string]any
if err := json.Unmarshal(inner, &parsed); err != nil {
@@ -941,7 +973,7 @@ returnResponse:
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回
// 用于处理客户端非流式请求但上游只支持流式的情况
func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) {
claudeResp, streamRes, err := s.collectClaudeStreamResponse(resp, startTime, originalModel)
claudeResp, streamRes, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel)
if err != nil {
var failoverErr *UpstreamFailoverError
if errors.As(err, &failoverErr) {
@@ -1120,6 +1152,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
}
lastDataAt = time.Now()
s.observeAntigravityGeminiSSELine(c, ev.line)
// 处理 SSE 行,转换为 Claude 格式
claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n"))
@@ -19,6 +19,7 @@ import (
// ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求
func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
startTime := time.Now()
sessionID := getSessionID(c)
prefix := logPrefix(sessionID, account.Name)
@@ -129,6 +130,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
}
// 提取 usage
upstreamResponseModelObserverFromContext(c).ObserveAnthropic(respBody)
usage = s.extractClaudeUsage(respBody)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
@@ -141,11 +143,13 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds())
return &ForwardResult{
Model: originalModel,
Stream: claudeReq.Stream,
Duration: duration,
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
Model: originalModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: claudeReq.Stream,
Duration: duration,
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
Usage: ClaudeUsage{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
@@ -247,6 +251,9 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp
lastDataAt = time.Now()
line := ev.line
if data, ok := extractAnthropicSSEDataLine(line); ok {
upstreamResponseModelObserverFromContext(c).ObserveAnthropic([]byte(strings.TrimSpace(data)))
}
// 记录首 token 时间
if firstTokenMs == nil && len(line) > 0 {
@@ -132,6 +132,20 @@ func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTi
return trend, nil
}
func (s *DashboardService) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) {
type usageTrendWithFiltersRepo interface {
GetUsageTrendWithUsageFilters(context.Context, time.Time, time.Time, string, usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error)
}
if repo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok {
trend, err := repo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters)
if err != nil {
return nil, fmt.Errorf("get usage trend with usage filters: %w", err)
}
return trend, nil
}
return s.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
}
func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
if err != nil {
@@ -161,6 +175,21 @@ func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context,
return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
}
func (s *DashboardService) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) {
normalizedSource := usagestats.NormalizeModelSource(modelSource)
type modelStatsWithFiltersRepo interface {
GetModelStatsWithUsageFiltersBySource(context.Context, time.Time, time.Time, usagestats.UsageLogFilters, string) ([]usagestats.ModelStat, error)
}
if repo, ok := s.usageRepo.(modelStatsWithFiltersRepo); ok {
stats, err := repo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource)
if err != nil {
return nil, fmt.Errorf("get model stats with usage filters by source: %w", err)
}
return stats, nil
}
return s.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource)
}
func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
if err != nil {
@@ -169,6 +198,20 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi
return stats, nil
}
func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) {
type groupStatsWithFiltersRepo interface {
GetGroupStatsWithUsageFilters(context.Context, time.Time, time.Time, usagestats.UsageLogFilters) ([]usagestats.GroupStat, error)
}
if repo, ok := s.usageRepo.(groupStatsWithFiltersRepo); ok {
stats, err := repo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters)
if err != nil {
return nil, fmt.Errorf("get group stats with usage filters: %w", err)
}
return stats, nil
}
return s.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType)
}
// GetGroupUsageSummary returns today's and cumulative cost for all groups.
func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart)
@@ -271,7 +271,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
if err != nil {
// 流中断时保留已观测到的 usage 与错误一起返回,避免上游已计量的请求
// 完全漏记漏计费(issue #5148)。
if partial := partialStreamUsageResult(resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil {
if partial := partialStreamUsageResult(c, resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil {
return partial, err
}
return nil, err
@@ -290,14 +290,16 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
}
return &ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: input.OriginalModel,
UpstreamModel: input.RequestModel,
Stream: input.RequestStream,
Duration: time.Since(input.StartTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: input.OriginalModel,
UpstreamModel: input.RequestModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: input.RequestStream,
Duration: time.Since(input.StartTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
}, nil
}
@@ -379,6 +381,10 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
startTime time.Time,
model string,
) (*streamingResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
if s.rateLimitService != nil {
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
}
@@ -532,6 +538,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
line := ev.line
if data, ok := extractAnthropicSSEDataLine(line); ok {
trimmed := strings.TrimSpace(data)
observer.ObserveAnthropic([]byte(trimmed))
if anthropicStreamEventIsTerminal("", trimmed) {
sawTerminalEvent = true
}
@@ -782,6 +789,11 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveAnthropic(body)
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
var raw json.RawMessage
+12 -9
View File
@@ -92,6 +92,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if parsed == nil {
return nil, fmt.Errorf("parse request: empty request")
}
beginUpstreamResponseModelObservation(c)
// Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应
if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) {
@@ -845,7 +846,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 流中断(缺失 terminal 事件、读错误、数据间隔超时等)时保留已观测到的
// usage 与错误一起返回,handler 在错误处理完成后照常提交 usage 记录。
if partial := partialStreamUsageResult(resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil {
if partial := partialStreamUsageResult(c, resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil {
return partial, err
}
return nil, err
@@ -861,14 +862,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
return &ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: originalModel, // 使用原始模型用于计费和日志
UpstreamModel: mappedModel,
Stream: reqStream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: originalModel, // 使用原始模型用于计费和日志
UpstreamModel: mappedModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: reqStream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
}, nil
}
+10 -6
View File
@@ -576,12 +576,16 @@ type ForwardResult struct {
Model string
// UpstreamModel is the actual upstream model after mapping.
// Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings.
UpstreamModel string
Stream bool
Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求)
ClientDisconnect bool // 客户端是否在流式传输过程中断开
ReasoningEffort *string
UpstreamModel string
// UpstreamResponseModel is captured from the raw successful upstream
// response before any client-facing rewrite or protocol conversion.
UpstreamResponseModel string
UpstreamResponseModelConflict bool
Stream bool
Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求)
ClientDisconnect bool // 客户端是否在流式传输过程中断开
ReasoningEffort *string
// 图片生成计费字段(图片生成模型使用)
ImageCount int // 生成的图片数量
@@ -665,7 +665,7 @@ func (u *ClaudeUsage) hasObservedTokens() bool {
//
// 不变式:UpstreamFailoverError 必须保持 result=nil——failover 重试成功后按成功请求
// 计费,若同时返回部分 usage 会造成双重计费,此处显式拦截兜底。
func partialStreamUsageResult(resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult {
func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult {
if streamResult == nil || !streamResult.usage.hasObservedTokens() {
return nil
}
@@ -674,18 +674,24 @@ func partialStreamUsageResult(resp *http.Response, streamResult *streamingResult
return nil
}
return &ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *streamResult.usage,
Model: model,
UpstreamModel: upstreamModel,
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: streamResult.firstTokenMs,
ClientDisconnect: streamResult.clientDisconnect,
RequestID: resp.Header.Get("x-request-id"),
Usage: *streamResult.usage,
Model: model,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: streamResult.firstTokenMs,
ClientDisconnect: streamResult.clientDisconnect,
}
}
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
// 更新5h窗口状态
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
@@ -881,6 +887,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
eventType, _ := event["type"].(string)
observer.ObserveAnthropic([]byte(dataLine))
if eventName == "" {
eventName = eventType
}
@@ -1376,6 +1383,11 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveAnthropic(body)
// 解析usage
var response struct {
@@ -990,6 +990,16 @@ func (s *GatewayService) buildRecordUsageLog(
) *UsageLog {
durationMs := int(result.Duration.Milliseconds())
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
sentModel := upstreamSentModel(result.Model, result.UpstreamModel)
if result.UpstreamResponseModelConflict {
slog.Warn("upstream_response_model_conflict",
"platform", account.Platform,
"account_id", account.ID,
"request_id", requestID,
"sent_model", sentModel,
"selected_response_model", strings.TrimSpace(result.UpstreamResponseModel),
)
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
@@ -998,6 +1008,8 @@ func (s *GatewayService) buildRecordUsageLog(
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
@@ -581,6 +581,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont
}
func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
startTime := time.Now()
var req struct {
@@ -1072,6 +1073,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream")
}
collectedBytes, _ := json.Marshal(collected)
upstreamResponseModelObserverFromContext(c).ObserveGemini(collectedBytes)
claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes, false)
c.JSON(http.StatusOK, claudeResp)
usage = usageObj2
@@ -1095,16 +1097,18 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
Stream: req.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: req.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil
}
@@ -1117,6 +1121,7 @@ func isGeminiSignatureRelatedError(respBody []byte) bool {
}
func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
startTime := time.Now()
if strings.TrimSpace(originalModel) == "" {
@@ -1603,6 +1608,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to read upstream stream")
}
b, _ := json.Marshal(collected)
upstreamResponseModelObserverFromContext(c).ObserveGemini(b)
c.Data(http.StatusOK, "application/json", b)
usage = usageObj
} else {
@@ -1627,16 +1633,18 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
Stream: stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
RequestID: requestID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil
}
@@ -1999,6 +2007,11 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context,
if err != nil {
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveGemini(unwrappedBody)
var geminiResp map[string]any
if err := json.Unmarshal(unwrappedBody, &geminiResp); err != nil {
@@ -2082,6 +2095,11 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
if err != nil {
continue
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveGemini(unwrappedBytes)
var geminiResp map[string]any
if err := json.Unmarshal(unwrappedBytes, &geminiResp); err != nil {
@@ -2583,6 +2601,11 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co
respBody = unwrappedBody
}
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveGemini(respBody)
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
@@ -2631,6 +2654,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
reader := bufio.NewReader(resp.Body)
usage := &ClaudeUsage{}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
var firstTokenMs *int
for {
@@ -2661,6 +2688,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
if u := extractGeminiUsage(rawBytes); u != nil {
usage = u
}
observer.ObserveGemini(rawBytes)
if firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
@@ -58,6 +58,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
promptCacheKey string,
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
restrictionResult := s.detectCodexClientRestriction(c, account, body)
logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body)
if restrictionResult.Enabled && !restrictionResult.Matched {
@@ -419,6 +421,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event")
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.Observe(finalResponse.Model, true)
if strings.TrimSpace(finalResponse.Status) == "failed" {
payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse})
// cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4),
@@ -477,13 +484,15 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
c.JSON(http.StatusOK, chatResp)
return &OpenAIForwardResult{
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
Stream: false,
Duration: time.Since(startTime),
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: false,
Duration: time.Since(startTime),
}, nil
}
@@ -517,6 +526,10 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen)
var streamFailoverErr *UpstreamFailoverError
var streamNonFailoverErr error
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
scanner := s.newUpstreamSSEScanner(resp.Body)
@@ -536,14 +549,16 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
resultWithUsage := func() *OpenAIForwardResult {
return &OpenAIForwardResult{
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}
}
@@ -562,6 +577,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
)
return false
}
observer.ObserveOpenAI([]byte(payload), event.Type)
refusalDetector.ObservePayload([]byte(payload))
isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type)
@@ -253,6 +253,10 @@ func (s *OpenAIGatewayService) streamRawChatCompletions(
startTime time.Time,
requestBodyLen int,
) (*OpenAIForwardResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
requestID := resp.Header.Get("x-request-id")
writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
scanner := s.newUpstreamSSEScanner(resp.Body)
@@ -302,6 +306,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions(
if payload, ok := extractOpenAISSEDataLine(line); ok {
trimmedPayload := strings.TrimSpace(payload)
if trimmedPayload != "[DONE]" {
observer.ObserveOpenAI([]byte(payload), strings.TrimSpace(gjson.Get(payload, "type").String()))
usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload)
if u := extractCCStreamUsage(payload); u != nil {
usage = *u
@@ -356,16 +361,18 @@ func (s *OpenAIGatewayService) streamRawChatCompletions(
}
return &OpenAIForwardResult{
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
ReasoningEffort: reasoningEffort,
ServiceTier: serviceTier,
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
ReasoningEffort: reasoningEffort,
ServiceTier: serviceTier,
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}, nil
}
@@ -425,6 +432,11 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions(
}
return nil, fmt.Errorf("read upstream body: %w", err)
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveOpenAI(respBody, strings.TrimSpace(gjson.GetBytes(respBody, "type").String()))
var usage OpenAIUsage
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
@@ -443,15 +455,17 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions(
_, _ = c.Writer.Write(respBody)
return &OpenAIForwardResult{
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
ReasoningEffort: reasoningEffort,
ServiceTier: serviceTier,
Stream: false,
Duration: time.Since(startTime),
RequestID: requestID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
ReasoningEffort: reasoningEffort,
ServiceTier: serviceTier,
Stream: false,
Duration: time.Since(startTime),
}, nil
}
@@ -19,6 +19,7 @@ import (
// Forward forwards request to OpenAI API
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
clearGrokResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
startTime := time.Now()
@@ -974,18 +975,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
forwardResult := &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
ResponseID: responseID,
Usage: *usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
ServiceTier: serviceTier,
ReasoningEffort: reasoningEffort,
Stream: reqStream,
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: resp.Header.Get("x-request-id"),
ResponseID: responseID,
Usage: *usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
ServiceTier: serviceTier,
ReasoningEffort: reasoningEffort,
Stream: reqStream,
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}
if imageCount > 0 {
forwardResult.ImageCount = imageCount
@@ -34,6 +34,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
promptCacheKey string,
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
// 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与
// ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求
// 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持
@@ -549,6 +551,11 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event")
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.Observe(finalResponse.Model, true)
if strings.TrimSpace(finalResponse.Status) == "failed" {
payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse})
@@ -602,14 +609,16 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
c.JSON(http.StatusOK, anthropicResp)
return &OpenAIForwardResult{
RequestID: requestID,
ResponseID: finalResponse.ID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
Stream: false,
Duration: time.Since(startTime),
RequestID: requestID,
ResponseID: finalResponse.ID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: false,
Duration: time.Since(startTime),
}, nil
}
@@ -846,20 +855,26 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
if intervalTicker != nil {
intervalCh = intervalTicker.C
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
// resultWithUsage builds the final result snapshot.
resultWithUsage := func() *OpenAIForwardResult {
return &OpenAIForwardResult{
RequestID: requestID,
ResponseID: responseID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnected,
RequestID: requestID,
ResponseID: responseID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: true,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnected,
}
}
@@ -879,6 +894,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
)
return false
}
observer.ObserveOpenAI([]byte(payload), event.Type)
eventType := strings.TrimSpace(event.Type)
isBareErrorEvent := eventType == "error"
@@ -278,17 +278,19 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
forwardResult := &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
ResponseID: responseID,
Usage: *usage,
Model: reqModel,
UpstreamModel: upstreamPassthroughModel,
ServiceTier: serviceTier,
ReasoningEffort: reasoningEffort,
Stream: reqStream,
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: resp.Header.Get("x-request-id"),
ResponseID: responseID,
Usage: *usage,
Model: reqModel,
UpstreamModel: upstreamPassthroughModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
ServiceTier: serviceTier,
ReasoningEffort: reasoningEffort,
Stream: reqStream,
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}
if imageCount > 0 {
forwardResult.ImageCount = imageCount
@@ -1051,6 +1053,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
originalModel string,
mappedModel string,
) (*openaiStreamingResultPassthrough, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
// SSE headers
@@ -1131,6 +1137,8 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if data, ok := extractOpenAISSEDataLine(line); ok {
dataBytes := []byte(data)
trimmedData := strings.TrimSpace(data)
rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
observer.ObserveOpenAI(dataBytes, rawEventType)
if needModelReplace && strings.Contains(data, mappedModel) {
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
if replacedData, replaced := extractOpenAISSEDataLine(line); replaced {
@@ -1317,6 +1325,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
if bodyHasSSEFraming(body) {
observeOpenAISSEBody(observer, string(body))
} else {
observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
}
// Detect SSE responses from upstream and convert to JSON.
// Some upstreams (e.g. other sub2api instances) may return SSE even when
@@ -43,6 +43,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
}
func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel, reasoningEffort string) (*openaiStreamingResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
firstOutputTimeout := time.Duration(0)
if account != nil && account.Platform == PlatformOpenAI {
firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort)
@@ -406,6 +410,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
dataBytes := []byte(data)
eventTypeRaw := gjson.GetBytes(dataBytes, "type").String()
eventType := strings.TrimSpace(eventTypeRaw)
observer.ObserveOpenAI(dataBytes, eventTypeRaw)
// 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。
if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) {
sawTerminalEvent = true
@@ -1110,6 +1115,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
if bodyHasSSEFraming(body) {
observeOpenAISSEBody(observer, string(body))
} else {
observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
}
// Detect SSE responses for ALL account types via Content-Type header.
// Some OpenAI-compatible upstreams (including other sub2api instances)
@@ -243,6 +243,10 @@ type OpenAIForwardResult struct {
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Empty when no mapping was applied (requested model was used as-is).
UpstreamModel string
// UpstreamResponseModel is captured from the raw successful upstream
// response before any client-facing rewrite or protocol conversion.
UpstreamResponseModel string
UpstreamResponseModelConflict bool
// UpstreamEndpoint is the actual upstream API path used for this request.
// It avoids guessing when one downstream protocol can use multiple upstream endpoints.
UpstreamEndpoint string
@@ -3456,6 +3456,32 @@ func TestHandleNonStreamingResponse_OAuthJSONBodyWithDataEventTextKeepsJSONUsage
require.Contains(t, rec.Body.String(), "processing data: 1,2,3 then event: click finished")
}
func TestHandleNonStreamingResponse_ObservesUpstreamModelBeforeClientRewrite(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
svc := &OpenAIGatewayService{cfg: &config.Config{}}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"resp_model_audit","object":"response","model":"gpt-5.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}`,
)),
}
account := &Account{ID: 1, Type: AccountTypeAPIKey}
result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "gpt-5.6-sol", "gpt-5.5")
require.NoError(t, err)
require.NotNil(t, result)
// 客户端仍看到自己请求的模型名,审计观察器则保留改写前的上游声明。
require.Equal(t, "gpt-5.6-sol", gjson.Get(rec.Body.String(), "model").String())
require.Equal(t, "gpt-5.5", observedUpstreamResponseModel(c))
require.False(t, observedUpstreamResponseModelConflict(c))
}
func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -263,31 +263,43 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if input.OriginalModel != "" {
requestedModel = input.OriginalModel
}
sentModel := upstreamSentModel(result.Model, result.UpstreamModel)
if result.UpstreamResponseModelConflict {
logger.L().Warn("upstream_response_model_conflict",
zap.String("platform", account.Platform),
zap.Int64("account_id", account.ID),
zap.String("request_id", requestID),
zap.String("sent_model", sentModel),
zap.String("selected_response_model", strings.TrimSpace(result.UpstreamResponseModel)),
)
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageInputTokens: result.Usage.ImageInputTokens,
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,
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageInputTokens: result.Usage.ImageInputTokens,
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,
}
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
if isVideoUsage {
@@ -785,6 +785,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) {
responseModelObserver := &upstreamResponseModelObserver{}
if lease == nil {
return nil, errors.New("upstream websocket lease is nil")
}
@@ -852,6 +853,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
responseModelObserver.ObserveOpenAI(upstreamMessage, eventType)
if responseID == "" && eventResponseID != "" {
responseID = eventResponseID
}
@@ -1026,18 +1028,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
imageCount := imageCounter.Count()
result := &OpenAIForwardResult{
RequestID: responseID,
Usage: usage,
Model: originalModel,
UpstreamModel: mappedModel,
ServiceTier: extractOpenAIServiceTierFromBody(payload),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: terminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(turnStart),
FirstTokenMs: firstTokenMs,
RequestID: responseID,
Usage: usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: responseModelObserver.Model(),
UpstreamResponseModelConflict: responseModelObserver.Conflict(),
ServiceTier: extractOpenAIServiceTierFromBody(payload),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: terminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(turnStart),
FirstTokenMs: firstTokenMs,
}
if replayInput := replayCollector.Items(); len(replayInput) > 0 {
result.wsReplayInput = replayInput
@@ -36,6 +36,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
if s == nil || account == nil {
return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil"))
}
responseModelObserver := &upstreamResponseModelObserver{}
wsURL, err := s.buildOpenAIResponsesWSURL(account)
if err != nil {
@@ -512,6 +513,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
if eventType == "" {
continue
}
responseModelObserver.ObserveOpenAI(message, eventType)
eventCount++
if firstEventType == "" {
firstEventType = eventType
@@ -748,20 +750,22 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
)
return &OpenAIForwardResult{
RequestID: responseID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
ImageCount: imageCounter.Count(),
ImageOutputSizes: imageCounter.Sizes(),
ServiceTier: extractOpenAIServiceTier(reqBody),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: responseID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: responseModelObserver.Model(),
UpstreamResponseModelConflict: responseModelObserver.Conflict(),
ImageCount: imageCounter.Count(),
ImageOutputSizes: imageCounter.Sizes(),
ServiceTier: extractOpenAIServiceTier(reqBody),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}, nil
}
@@ -180,6 +180,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
if writeClientMessage == nil {
return nil, errors.New("client websocket writer is nil")
}
responseModelObserver := &upstreamResponseModelObserver{}
body, err := prepareOpenAIWSHTTPBridgeBody(payload)
if err != nil {
@@ -299,18 +300,20 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
resultWithUsage := func() *OpenAIForwardResult {
imageCount := imageCounter.Count()
result := &OpenAIForwardResult{
RequestID: responseID,
Usage: usage,
Model: originalModel,
UpstreamModel: mappedModel,
ServiceTier: extractOpenAIServiceTierFromBody(body),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: cloneHeader(resp.Header),
Duration: time.Since(turnStart),
FirstTokenMs: firstTokenMs,
RequestID: responseID,
Usage: usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: responseModelObserver.Model(),
UpstreamResponseModelConflict: responseModelObserver.Conflict(),
ServiceTier: extractOpenAIServiceTierFromBody(body),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: cloneHeader(resp.Header),
Duration: time.Since(turnStart),
FirstTokenMs: firstTokenMs,
}
if replayInput := replayCollector.Items(); len(replayInput) > 0 {
result.wsReplayInput = replayInput
@@ -355,6 +358,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
upstreamMessage = normalized
}
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
responseModelObserver.ObserveOpenAI(upstreamMessage, eventType)
if responseID == "" && eventResponseID != "" {
responseID = eventResponseID
}
@@ -31,6 +31,8 @@ type Usage struct {
type RelayResult struct {
RequestModel string
ResponseModel string
ResponseModelConflict bool
Usage Usage
RequestID string
TerminalEventType string
@@ -42,12 +44,14 @@ type RelayResult struct {
}
type RelayTurnResult struct {
RequestModel string
Usage Usage
RequestID string
TerminalEventType string
Duration time.Duration
FirstTokenMs *int
RequestModel string
ResponseModel string
ResponseModelConflict bool
Usage Usage
RequestID string
TerminalEventType string
Duration time.Duration
FirstTokenMs *int
}
type RelayExit struct {
@@ -90,6 +94,8 @@ type relayState struct {
requestModelMu sync.RWMutex
requestModel string
lastResponseID string
lastResponseModel string
responseConflict bool
terminalEventType string
firstTokenMs *int
turnTimingByID map[string]*relayTurnTiming
@@ -104,17 +110,22 @@ type relayExitSignal struct {
}
type observedUpstreamEvent struct {
terminal bool
eventType string
responseID string
usage Usage
duration time.Duration
firstToken *int
terminal bool
eventType string
responseID string
usage Usage
responseModel string
responseConflict bool
duration time.Duration
firstToken *int
}
type relayTurnTiming struct {
startAt time.Time
firstTokenMs *int
startAt time.Time
firstTokenMs *int
firstResponseModel string
terminalResponseModel string
responseModelConflict bool
}
func Relay(
@@ -684,15 +695,19 @@ func observeUpstreamMessage(
responseID: responseID,
usage: parsedUsage,
}
var turnTiming *relayTurnTiming
if responseID != "" {
turnTiming := openAIWSRelayGetOrInitTurnTiming(state, responseID, now)
turnTiming = openAIWSRelayGetOrInitTurnTiming(state, responseID, now)
if turnTiming != nil && turnTiming.firstTokenMs == nil && isTokenEvent(eventType) {
ms := int(now.Sub(turnTiming.startAt).Milliseconds())
if ms >= 0 {
turnTiming.firstTokenMs = &ms
}
}
} else {
turnTiming = state.activeTurn
}
observeRelayTurnResponseModel(turnTiming, firstRelayResponseModel(message), isTerminalEvent(eventType))
if !isTerminalEvent(eventType) {
return observed
}
@@ -701,6 +716,10 @@ func observeUpstreamMessage(
if responseID != "" {
state.lastResponseID = responseID
if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok {
observed.responseModel = relayTurnResponseModel(&turnTiming)
observed.responseConflict = turnTiming.responseModelConflict
state.lastResponseModel = observed.responseModel
state.responseConflict = observed.responseConflict
duration := now.Sub(turnTiming.startAt)
if duration < 0 {
duration = 0
@@ -729,15 +748,64 @@ func emitTurnComplete(
requestModel = state.currentRequestModel()
}
onTurnComplete(RelayTurnResult{
RequestModel: requestModel,
Usage: observed.usage,
RequestID: responseID,
TerminalEventType: observed.eventType,
Duration: observed.duration,
FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken),
RequestModel: requestModel,
ResponseModel: observed.responseModel,
ResponseModelConflict: observed.responseConflict,
Usage: observed.usage,
RequestID: responseID,
TerminalEventType: observed.eventType,
Duration: observed.duration,
FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken),
})
}
func firstRelayResponseModel(message []byte) string {
if len(message) == 0 {
return ""
}
values := gjson.GetManyBytes(message, "response.model", "model")
for _, value := range values {
if value.Type != gjson.String {
continue
}
if model := strings.TrimSpace(value.String()); model != "" {
return model
}
}
return ""
}
func observeRelayTurnResponseModel(turn *relayTurnTiming, model string, terminal bool) {
if turn == nil {
return
}
model = strings.TrimSpace(model)
if model == "" {
return
}
current := relayTurnResponseModel(turn)
if current != "" && !strings.EqualFold(current, model) {
turn.responseModelConflict = true
}
if terminal {
turn.terminalResponseModel = model
return
}
if turn.firstResponseModel == "" {
turn.firstResponseModel = model
}
}
func relayTurnResponseModel(turn *relayTurnTiming) string {
if turn == nil {
return ""
}
if turn.terminalResponseModel != "" {
return turn.terminalResponseModel
}
return turn.firstResponseModel
}
func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming {
if state == nil {
return nil
@@ -888,6 +956,8 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration
return
}
result.RequestModel = state.currentRequestModel()
result.ResponseModel = state.lastResponseModel
result.ResponseModelConflict = state.responseConflict
result.Usage = state.usage
result.RequestID = state.lastResponseID
result.TerminalEventType = state.terminalEventType
@@ -457,6 +457,60 @@ func TestRelayTurnTimingHelpersCoverage(t *testing.T) {
require.False(t, ok)
}
func TestObserveUpstreamMessage_ResponseModelIsTurnLocalAndTerminalWins(t *testing.T) {
t.Parallel()
state := &relayState{requestModel: "gpt-5.6-sol"}
startAt := time.Unix(0, 0)
now := startAt
nowFn := func() time.Time {
now = now.Add(5 * time.Millisecond)
return now
}
created := observeUpstreamMessage(
state,
[]byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.5"}}`),
startAt,
nowFn,
nil,
)
require.False(t, created.terminal)
completed := observeUpstreamMessage(
state,
[]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":2}}}`),
startAt,
nowFn,
nil,
)
require.True(t, completed.terminal)
require.Equal(t, "gpt-5.4", completed.responseModel)
require.True(t, completed.responseConflict)
var firstTurn RelayTurnResult
emitTurnComplete(func(turn RelayTurnResult) { firstTurn = turn }, state, completed)
require.Equal(t, "gpt-5.4", firstTurn.ResponseModel)
require.True(t, firstTurn.ResponseModelConflict)
observeUpstreamMessage(
state,
[]byte(`{"type":"response.created","response":{"id":"resp_2","model":"gpt-5.3"}}`),
startAt,
nowFn,
nil,
)
second := observeUpstreamMessage(
state,
[]byte(`{"type":"response.completed","response":{"id":"resp_2","model":"GPT-5.3","usage":{"input_tokens":3,"output_tokens":4}}}`),
startAt,
nowFn,
nil,
)
require.Equal(t, "GPT-5.3", second.responseModel)
require.False(t, second.responseConflict, "the previous turn must not contaminate this turn")
}
func TestObserveUpstreamMessage_ResponseIDFallbackPolicy(t *testing.T) {
t.Parallel()
@@ -1103,16 +1103,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
CacheReadInputTokens: turn.Usage.CacheReadInputTokens,
ImageOutputTokens: turn.Usage.ImageOutputTokens,
},
Model: turnRequestModel,
UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel),
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType),
ResponseHeaders: cloneHeader(handshakeHeaders),
Duration: turn.Duration,
FirstTokenMs: turn.FirstTokenMs,
Model: turnRequestModel,
UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel),
UpstreamResponseModel: turn.ResponseModel,
UpstreamResponseModelConflict: turn.ResponseModelConflict,
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType),
ResponseHeaders: cloneHeader(handshakeHeaders),
Duration: turn.Duration,
FirstTokenMs: turn.FirstTokenMs,
}
logOpenAIWSV2Passthrough(
"relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s turn_requested_model=%s turn_upstream_model=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d",
@@ -1222,16 +1224,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens,
ImageOutputTokens: relayResult.Usage.ImageOutputTokens,
},
Model: resultRequestModel,
UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel),
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType),
ResponseHeaders: cloneHeader(handshakeHeaders),
Duration: relayResult.Duration,
FirstTokenMs: relayResult.FirstTokenMs,
Model: resultRequestModel,
UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel),
UpstreamResponseModel: relayResult.ResponseModel,
UpstreamResponseModelConflict: relayResult.ResponseModelConflict,
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType),
ResponseHeaders: cloneHeader(handshakeHeaders),
Duration: relayResult.Duration,
FirstTokenMs: relayResult.FirstTokenMs,
}
turnCount := int(completedTurns.Load())
@@ -0,0 +1,179 @@
package service
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
const (
upstreamResponseModelObserverContextKey = "upstream_response_model_observer"
upstreamResponseModelMaxLength = 200
)
// upstreamResponseModelObserver tracks one forwarding attempt (or one WS turn).
// A terminal declaration wins over an earlier declaration; otherwise the first
// declaration is retained. Conflicts are diagnostic only and never affect the
// forwarding or billing path.
type upstreamResponseModelObserver struct {
first string
terminal string
conflict bool
}
func (o *upstreamResponseModelObserver) Observe(model string, terminal bool) {
model = normalizeObservedUpstreamResponseModel(model)
if model == "" {
return
}
current := o.Model()
if current != "" && !strings.EqualFold(current, model) {
o.conflict = true
}
if terminal {
o.terminal = model
return
}
if o.first == "" {
o.first = model
}
}
func normalizeObservedUpstreamResponseModel(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
runes := []rune(model)
if len(runes) > upstreamResponseModelMaxLength {
model = string(runes[:upstreamResponseModelMaxLength])
}
return model
}
func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return
}
model := firstTrimmedGJSONModel(
gjson.GetBytes(payload, "response.model"),
gjson.GetBytes(payload, "model"),
)
o.Observe(model, isUpstreamResponseModelTerminalEvent(eventType))
}
func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return
}
model := firstTrimmedGJSONModel(
gjson.GetBytes(payload, "message.model"),
gjson.GetBytes(payload, "model"),
)
o.Observe(model, false)
}
func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return
}
model := firstTrimmedGJSONModel(
gjson.GetBytes(payload, "modelVersion"),
gjson.GetBytes(payload, "response.modelVersion"),
)
// Gemini streaming has no universal terminal event carrying modelVersion;
// treating each declaration as terminal retains the latest chunk.
o.Observe(model, true)
}
func (o *upstreamResponseModelObserver) Model() string {
if o == nil {
return ""
}
if o.terminal != "" {
return o.terminal
}
return o.first
}
func (o *upstreamResponseModelObserver) Conflict() bool {
return o != nil && o.conflict
}
func beginUpstreamResponseModelObservation(c *gin.Context) *upstreamResponseModelObserver {
observer := &upstreamResponseModelObserver{}
if c != nil {
c.Set(upstreamResponseModelObserverContextKey, observer)
}
return observer
}
func upstreamResponseModelObserverFromContext(c *gin.Context) *upstreamResponseModelObserver {
if c == nil {
return nil
}
value, ok := c.Get(upstreamResponseModelObserverContextKey)
if !ok {
return nil
}
observer, _ := value.(*upstreamResponseModelObserver)
return observer
}
func observedUpstreamResponseModel(c *gin.Context) string {
return upstreamResponseModelObserverFromContext(c).Model()
}
func observedUpstreamResponseModelConflict(c *gin.Context) bool {
return upstreamResponseModelObserverFromContext(c).Conflict()
}
func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) {
if observer == nil || strings.TrimSpace(body) == "" {
return
}
forEachOpenAISSEDataPayload(body, func(payload []byte) {
eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
observer.ObserveOpenAI(payload, eventType)
})
}
func firstTrimmedGJSONModel(values ...gjson.Result) string {
for _, value := range values {
if !value.Exists() || value.Type != gjson.String {
continue
}
if model := strings.TrimSpace(value.String()); model != "" {
return model
}
}
return ""
}
func isUpstreamResponseModelTerminalEvent(eventType string) bool {
switch strings.TrimSpace(eventType) {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
return true
default:
return false
}
}
func upstreamModelMismatch(sentModel, responseModel string) *bool {
responseModel = strings.TrimSpace(responseModel)
if responseModel == "" {
return nil
}
sentModel = strings.TrimSpace(sentModel)
mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel)
return &mismatch
}
func upstreamSentModel(requestedModel, upstreamModel string) string {
sentModel := strings.TrimSpace(upstreamModel)
if sentModel == "" {
sentModel = strings.TrimSpace(requestedModel)
}
return sentModel
}
@@ -0,0 +1,75 @@
package service
import (
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestUpstreamResponseModelObserverTerminalWinsAndRecordsConflict(t *testing.T) {
observer := &upstreamResponseModelObserver{}
observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5"}}`), "response.created")
observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.4"}}`), "response.completed")
require.Equal(t, "gpt-5.4", observer.Model())
require.True(t, observer.Conflict())
}
func TestUpstreamResponseModelObserverSupportsAnthropicAndGeminiShapes(t *testing.T) {
t.Run("anthropic", func(t *testing.T) {
observer := &upstreamResponseModelObserver{}
observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"model":"claude-sonnet-4-20250514"}}`))
require.Equal(t, "claude-sonnet-4-20250514", observer.Model())
})
t.Run("gemini outer and nested", func(t *testing.T) {
observer := &upstreamResponseModelObserver{}
observer.ObserveGemini([]byte(`{"response":{"modelVersion":"gemini-2.5-pro"}}`))
observer.ObserveGemini([]byte(`{"modelVersion":"gemini-2.5-pro-latest"}`))
require.Equal(t, "gemini-2.5-pro-latest", observer.Model())
require.True(t, observer.Conflict())
})
}
func TestUpstreamResponseModelObservationAttemptReset(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
first := beginUpstreamResponseModelObservation(c)
first.Observe("failed-attempt-model", false)
second := beginUpstreamResponseModelObservation(c)
second.Observe("successful-attempt-model", false)
require.Equal(t, "successful-attempt-model", observedUpstreamResponseModel(c))
require.False(t, observedUpstreamResponseModelConflict(c))
}
func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing.T) {
require.Nil(t, upstreamModelMismatch("gpt-5.5", ""))
matched := upstreamModelMismatch("gpt-5.5", "GPT-5.5")
require.NotNil(t, matched)
require.False(t, *matched)
mismatched := upstreamModelMismatch("gpt-5.5", "gpt-5.4")
require.NotNil(t, mismatched)
require.True(t, *mismatched)
}
func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) {
observer := &upstreamResponseModelObserver{}
observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n")
require.Equal(t, "gpt-5.4", observer.Model())
require.False(t, observer.Conflict())
}
func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) {
observer := &upstreamResponseModelObserver{}
observer.Observe(" "+strings.Repeat("模", upstreamResponseModelMaxLength+1)+" ", false)
require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength)
}
+6
View File
@@ -114,6 +114,12 @@ type UsageLog struct {
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Nil means no mapping was applied (requested model was used as-is).
UpstreamModel *string
// UpstreamResponseModel is the model declared by the successful upstream
// response before client-facing model rewrites or protocol conversion.
UpstreamResponseModel *string
// UpstreamModelMismatch is nil when no upstream model was observed. Otherwise
// it compares UpstreamResponseModel with the actual model sent upstream.
UpstreamModelMismatch *bool
// ChannelID 渠道 ID
ChannelID *int64
// ModelMappingChain 模型映射链,如 "a→b→c"
@@ -0,0 +1,3 @@
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS upstream_response_model VARCHAR(200),
ADD COLUMN IF NOT EXISTS upstream_model_mismatch BOOLEAN;
@@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at
ON usage_logs (created_at DESC, id DESC)
WHERE upstream_model_mismatch IS TRUE;
+3
View File
@@ -56,6 +56,7 @@ export interface TrendParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface TrendResponse {
@@ -87,6 +88,7 @@ export interface ModelStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface ModelStatsResponse {
@@ -115,6 +117,7 @@ export interface GroupStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface GroupStatsResponse {
+2
View File
@@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number
exact_total?: boolean
billing_mode?: string
upstream_model_mismatch?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
@@ -123,6 +124,7 @@ export async function getStats(params: {
model?: string
request_type?: UsageRequestType
stream?: boolean
upstream_model_mismatch?: boolean
period?: string
start_date?: string
end_date?: string
@@ -139,6 +139,11 @@
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.upstreamModelAudit') }}</label>
<Select v-model="filters.upstream_model_mismatch" :options="upstreamModelMismatchOptions" @change="emitChange" />
</div>
<!-- Error Phase Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
@@ -307,6 +312,12 @@ const billingModeOptions = ref<SelectOption[]>([
{ value: 'video', label: t('admin.usage.billingModeVideo') }
])
const upstreamModelMismatchOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allUpstreamModelAudit') },
{ value: true, label: t('admin.usage.upstreamModelMismatchOnly') },
{ value: false, label: t('admin.usage.upstreamModelMatchedOnly') }
])
const emitChange = () => emit('change')
const clearPendingUserSearch = () => {
@@ -53,23 +53,41 @@
</template>
<template #cell-model="{ row }">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5 text-xs">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
<div class="space-y-0.5 text-xs">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
<div
v-if="row.upstream_model_mismatch === true && row.upstream_response_model"
class="break-all pl-3 text-[11px]"
:class="isLikelyModelVariant(row) ? 'text-amber-600 dark:text-amber-400' : 'text-orange-600 dark:text-orange-400'"
:title="modelAuditTitle(row)"
>
<span class="mr-1">↳ {{ t('usage.upstreamResponseModel') }}:</span>{{ row.upstream_response_model }}
<span
class="ml-1 inline-flex rounded px-1 py-px text-[10px] font-medium ring-1 ring-inset"
:class="isLikelyModelVariant(row)
? 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30'
: 'bg-orange-50 text-orange-700 ring-orange-200 dark:bg-orange-500/10 dark:text-orange-300 dark:ring-orange-500/30'"
>
{{ isLikelyModelVariant(row) ? t('usage.modelVariant') : t('usage.modelMismatch') }}
</span>
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
</template>
<template #cell-reasoning_effort="{ row }">
@@ -548,6 +566,27 @@ const ipGeoBatchLoading = ref(false)
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || ''
const normalizeModelVariant = (model: string): string => model
.trim()
.toLowerCase()
.replace(/-latest$/, '')
.replace(/-\d{4}-\d{2}-\d{2}$/, '')
.replace(/-\d{8}$/, '')
const isLikelyModelVariant = (row: AdminUsageLog): boolean => {
const sent = sentUpstreamModel(row)
const response = row.upstream_response_model?.trim() || ''
return sent !== '' && response !== '' && normalizeModelVariant(sent) === normalizeModelVariant(response)
}
const modelAuditTitle = (row: AdminUsageLog): string => [
`${t('usage.requestedModel')}: ${row.model || '-'}`,
`${t('usage.sentUpstreamModel')}: ${sentUpstreamModel(row) || '-'}`,
`${t('usage.upstreamResponseModel')}: ${row.upstream_response_model || '-'}`,
].join('\n')
const currentPageIps = computed(() =>
Array.from(new Set(props.data.map((row) => row.ip_address).filter((ip): ip is string => Boolean(ip))))
)
@@ -28,6 +28,10 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per Request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.upstreamModelAudit': 'Upstream model audit',
'admin.usage.allUpstreamModelAudit': 'All response model states',
'admin.usage.upstreamModelMismatchOnly': 'Mismatched only',
'admin.usage.upstreamModelMatchedOnly': 'Matched only',
'admin.usage.group': 'Group',
'admin.usage.allGroups': 'All Groups',
'common.refresh': 'Refresh',
@@ -75,6 +79,7 @@ const defaultFilters = () => ({
request_type: null,
billing_type: null,
billing_mode: null,
upstream_model_mismatch: null,
group_id: null,
start_date: '',
end_date: '',
@@ -51,6 +51,11 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'usage.requestedModel': 'Requested',
'usage.sentUpstreamModel': 'Sent upstream',
'usage.upstreamResponseModel': 'Upstream response',
'usage.modelVariant': 'Possible version variant',
'usage.modelMismatch': 'Different model',
}
vi.mock('vue-i18n', async () => {
@@ -240,6 +245,48 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4-20250514')
})
it.each([
{
name: 'possible version variant',
responseModel: 'gpt-5.5-2026-08-01',
expectedBadge: 'Possible version variant',
},
{
name: 'different upstream model',
responseModel: 'gpt-5.4',
expectedBadge: 'Different model',
},
])('shows a compact upstream response audit marker for $name', ({ responseModel, expectedBadge }) => {
const wrapper = mount(UsageTable, {
props: {
data: [{
request_id: `req-${responseModel}`,
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
model_mapping_chain: 'gpt-5.6-sol→gpt-5.5',
upstream_response_model: responseModel,
upstream_model_mismatch: true,
}],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('gpt-5.6-sol')
expect(text).toContain('gpt-5.5')
expect(text).toContain(responseModel)
expect(text).toContain(expectedBadge)
})
it.each([
{
name: 'defaulted row',
@@ -544,6 +544,10 @@ export default {
billingModeImage: 'Image',
billingModeVideo: 'Video',
allBillingModes: 'All Billing Modes',
upstreamModelAudit: 'Upstream model audit',
allUpstreamModelAudit: 'All response model states',
upstreamModelMismatchOnly: 'Mismatched only',
upstreamModelMatchedOnly: 'Matched only',
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info',
@@ -295,6 +295,11 @@ export default {
model: 'Model',
requestedModel: 'Requested',
upstreamModel: 'Upstream',
sentUpstreamModel: 'Sent upstream',
upstreamResponseModel: 'Upstream response',
upstreamModelMismatch: 'Response model mismatch',
modelVariant: 'Possible version variant',
modelMismatch: 'Different model',
reasoningEffort: 'Reasoning Effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
@@ -541,6 +541,10 @@ export default {
billingModeImage: '按次(图片)',
billingModeVideo: '按次(视频)',
allBillingModes: '全部计费模式',
upstreamModelAudit: '上游模型审计',
allUpstreamModelAudit: '全部响应模型状态',
upstreamModelMismatchOnly: '仅不一致',
upstreamModelMatchedOnly: '仅一致',
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
@@ -300,6 +300,11 @@ export default {
model: '模型',
requestedModel: '请求',
upstreamModel: '上游',
sentUpstreamModel: '发往上游',
upstreamResponseModel: '上游响应',
upstreamModelMismatch: '上游响应模型不一致',
modelVariant: '疑似版本变体',
modelMismatch: '模型不一致',
reasoningEffort: '推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
+2
View File
@@ -1649,6 +1649,8 @@ export interface UsageLogAccountSummary {
export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null
upstream_response_model?: string | null
upstream_model_mismatch?: boolean | null
model_mapping_chain?: string | null
// 账号计费倍率(仅管理员可见)
+4 -2
View File
@@ -450,6 +450,7 @@ const loadModelStats = async (source: ModelDistributionSource, force = false) =>
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
}
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
@@ -499,6 +500,7 @@ const loadChartData = async () => {
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
include_stats: false,
include_trend: true,
include_model_stats: false,
@@ -573,7 +575,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
@@ -593,7 +595,7 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
@@ -4,7 +4,7 @@ import { defineComponent, ref } from 'vue'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => {
const { list, exportList, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery, aoaToSheet, sheetAddAoa, saveAs, xlsxWrite } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@@ -13,12 +13,17 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, ro
return {
list: vi.fn(),
exportList: vi.fn(),
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
routeQuery: {} as Record<string, string>,
aoaToSheet: vi.fn(() => ({})),
sheetAddAoa: vi.fn(),
saveAs: vi.fn(),
xlsxWrite: vi.fn(() => new Uint8Array([1, 2, 3])),
}
})
@@ -27,6 +32,12 @@ const messages: Record<string, string> = {
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
'usage.requestedModel': 'Requested model',
'usage.sentUpstreamModel': 'Sent upstream model',
'usage.upstreamResponseModel': 'Upstream response model',
'usage.upstreamModelMismatch': 'Upstream model mismatch',
'common.yes': 'Yes',
'common.no': 'No',
}
const formatLocalDate = (date: Date): string => {
@@ -54,10 +65,22 @@ vi.mock('@/api/admin', () => ({
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: vi.fn(),
list: exportList,
},
}))
vi.mock('file-saver', () => ({ saveAs }))
vi.mock('xlsx', () => ({
utils: {
aoa_to_sheet: aoaToSheet,
sheet_add_aoa: sheetAddAoa,
book_new: vi.fn(() => ({})),
book_append_sheet: vi.fn(),
},
write: xlsxWrite,
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
@@ -539,3 +562,62 @@ describe('admin UsageView ranking tab', () => {
expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 5 }), expect.anything())
})
})
describe('admin UsageView model audit export', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 })
exportList.mockReset().mockResolvedValue({
items: [{
id: 1,
created_at: '2026-08-04T00:00:00Z',
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
upstream_response_model: 'gpt-5.4',
upstream_model_mismatch: true,
request_type: 'sync',
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_creation_tokens: 0,
duration_ms: 10,
}],
total: 1,
pages: 1,
})
getStats.mockReset().mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockReset().mockResolvedValue({ models: [] })
aoaToSheet.mockClear()
sheetAddAoa.mockClear()
saveAs.mockClear()
xlsxWrite.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('exports requested, sent, response, and mismatch as separate admin columns', async () => {
const wrapper = mountRouteFilteredUsageView()
vi.advanceTimersByTime(120)
await flushPromises()
await (wrapper.vm as any).exportToExcel()
await flushPromises()
const headers = aoaToSheet.mock.calls[0][0][0]
expect(headers.slice(4, 8)).toEqual([
'Requested model',
'Sent upstream model',
'Upstream response model',
'Upstream model mismatch',
])
const row = sheetAddAoa.mock.calls[0][1][0]
expect(row.slice(4, 8)).toEqual(['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'Yes'])
expect(saveAs).toHaveBeenCalledTimes(1)
})
})