diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 0475bca3bf..92cdb7218d 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 61ff4f9e6d..98fce86905 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -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 diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e85660dcc1..b50b50cf58 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -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() diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index 6d8c2d4191..f13d07170c 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -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("计费层级标签"), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index b13e29b2f7..1bd424f8d5 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -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)) diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index a87d937195..d3f8dfa1b3 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -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() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index a9462e0d0e..7982d74c5f 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -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)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 31cf45328e..8178fa0710 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -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) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 2a60d6f44d..6c23ebd355 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -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) } diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 8f55fb4165..c94ed033fa 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -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 diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 6056f725b5..3316557dba 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -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{ diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 815c516154..4f79fb4500 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -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 diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 517ae7bd14..b19c9360aa 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -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 } diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index ad2bbecd22..829730c647 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -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 diff --git a/backend/internal/handler/admin/usage_query_cache.go b/backend/internal/handler/admin/usage_query_cache.go index b288a95ba4..f6b7746e1b 100644 --- a/backend/internal/handler/admin/usage_query_cache.go +++ b/backend/internal/handler/admin/usage_query_cache.go @@ -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, }) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index c6f6cf25e2..8687e79741 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index 6cbcbada56..d396bbfa2f 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -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) { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 37927c2b48..1761c4aaae 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index 8dc9a8b442..1e480d2e34 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -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":`) diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 959907ca77..a100b2ccaa 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -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 } diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index c5e13b25b1..b28cef1ca3 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -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 } diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go index 4ab6274b92..55588bb043 100644 --- a/backend/internal/repository/migrations_runner_notx_test.go +++ b/backend/internal/repository/migrations_runner_notx_test.go @@ -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) diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index b393bc15b6..e4af36ae25 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -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, diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 809b0652b5..6d932c0d43 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -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, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 084b79ad97..f2fa6e30b0 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -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 diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 047d0bf65f..a2b82dfa60 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -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, diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index aeccd49b30..53d60b7f4a 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -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) diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index 44052d07cc..e9025b1bd1 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -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) diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 76863911c6..6d8bd79eaa 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -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...) diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go index 7804dce306..64c1c1bab2 100644 --- a/backend/internal/repository/usage_log_session_id_unit_test.go +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -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)) diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 2011625984..3511f60288 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -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 } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index af1b8ebbe4..ab8f0d17a2 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -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) } diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index 3243193ceb..26d30ddb46 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -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: diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index f0d20244a8..2b622afd37 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -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 } diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 2810162c45..0b3a672853 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -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")) diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index 2914863131..7254745698 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -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 { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3e059e3069..d815ed6ef5 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -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) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index e46977f37d..c18d5bf047 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -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 diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index b862dfb7c0..1c1d41a711 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -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 } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index bb4ce85476..da246af030 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 // 生成的图片数量 diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 2ddc831992..5a9fedf964 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -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 { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0d07b355c6..8deefe3d6e 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -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), diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index e4df8982d3..fcedf9a21f 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -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()) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 171a6c8805..0b135bbb06 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 49cd591829..0a7cd98ec7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index bb13dd5989..f32cef7474 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 066fc82cec..08958f87b6 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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" diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index b1a3d189cf..4aaeaf6ade 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -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 diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 2a1993c463..9b833b750b 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -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) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 135f63aa81..b875d89237 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -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 diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 4ec35804c7..f9865501a9 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -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() diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 0725c34cf7..642a021ffa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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 { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 400ae714b8..cf579d5cee 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -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 diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 62aee29c20..cebca5993c 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -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 } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index b2c7c67f64..ccef029188 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -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 } diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index b6936fe994..b3fe6d3084 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -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 diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index 6036be7451..ed403e3448 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -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() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index ae74027bca..483ea8e94c 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -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()) diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go new file mode 100644 index 0000000000..2452c382d0 --- /dev/null +++ b/backend/internal/service/upstream_response_model.go @@ -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 +} diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go new file mode 100644 index 0000000000..37d382e851 --- /dev/null +++ b/backend/internal/service/upstream_response_model_test.go @@ -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) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index b35d149ea8..7a41555ece 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -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" diff --git a/backend/migrations/194_add_usage_log_upstream_response_model.sql b/backend/migrations/194_add_usage_log_upstream_response_model.sql new file mode 100644 index 0000000000..a5865aca17 --- /dev/null +++ b/backend/migrations/194_add_usage_log_upstream_response_model.sql @@ -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; diff --git a/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql new file mode 100644 index 0000000000..811ca8786c --- /dev/null +++ b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql @@ -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; diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index ae20d33f8e..5117039f61 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -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 { diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 83be033c11..1c740caabe 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -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 diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 0965567cec..1d52eb9477 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -139,6 +139,11 @@ +