diff --git a/backend/ent/group.go b/backend/ent/group.go index 19c272b711..bb49f43820 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -124,6 +124,8 @@ type Group struct { AllowLive bool `json:"allow_live,omitempty"` // 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority ForceOpenaiFast bool `json:"force_openai_fast,omitempty"` + // 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费 + FreeOpenaiFast bool `json:"free_openai_fast,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -256,7 +258,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) @@ -626,6 +628,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ForceOpenaiFast = value.Bool } + case group.FieldFreeOpenaiFast: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i]) + } else if value.Valid { + _m.FreeOpenaiFast = value.Bool + } case group.FieldRequireOauthOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i]) @@ -974,6 +982,9 @@ func (_m *Group) String() string { builder.WriteString("force_openai_fast=") builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast)) builder.WriteString(", ") + builder.WriteString("free_openai_fast=") + builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast)) + builder.WriteString(", ") builder.WriteString("require_oauth_only=") builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 4d31d147f3..2b634acee5 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -120,6 +120,8 @@ const ( FieldAllowLive = "allow_live" // FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database. FieldForceOpenaiFast = "force_openai_fast" + // FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database. + FieldFreeOpenaiFast = "free_openai_fast" // FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database. FieldRequireOauthOnly = "require_oauth_only" // FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database. @@ -271,6 +273,7 @@ var Columns = []string{ FieldAllowMessagesDispatch, FieldAllowLive, FieldForceOpenaiFast, + FieldFreeOpenaiFast, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -394,6 +397,8 @@ var ( DefaultAllowLive bool // DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field. DefaultForceOpenaiFast bool + // DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field. + DefaultFreeOpenaiFast bool // DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field. DefaultRequireOauthOnly bool // DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field. @@ -674,6 +679,11 @@ func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc() } +// ByFreeOpenaiFast orders the results by the free_openai_fast field. +func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc() +} + // ByRequireOauthOnly orders the results by the require_oauth_only field. func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 5286980859..149f2da8a0 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -295,6 +295,11 @@ func ForceOpenaiFast(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ. +func FreeOpenaiFast(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ. func RequireOauthOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) @@ -2290,6 +2295,16 @@ func ForceOpenaiFastNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + +// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field. func RequireOauthOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index a3b0115a68..75b6ed1151 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -718,6 +718,20 @@ func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate { return _c } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate { + _c.mutation.SetFreeOpenaiFast(v) + return _c +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate { + if v != nil { + _c.SetFreeOpenaiFast(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -1127,6 +1141,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultForceOpenaiFast _c.mutation.SetForceOpenaiFast(v) } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + v := group.DefaultFreeOpenaiFast + _c.mutation.SetFreeOpenaiFast(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1325,6 +1343,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.ForceOpenaiFast(); !ok { return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)} } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)} + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1611,6 +1632,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) _node.ForceOpenaiFast = value } + if value, ok := _c.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + _node.FreeOpenaiFast = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2685,6 +2710,18 @@ func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert { return u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert { + u.Set(group.FieldFreeOpenaiFast, v) + return u +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert { + u.SetExcluded(group.FieldFreeOpenaiFast) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3910,6 +3947,20 @@ func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5328,6 +5379,20 @@ func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 7ad2fe02a5..fac039fa95 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1003,6 +1003,20 @@ func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1833,6 +1847,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -3163,6 +3180,20 @@ func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -4023,6 +4054,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 7319aa692d..59aba5be05 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -955,6 +955,7 @@ var ( {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, {Name: "allow_live", Type: field.TypeBool, Default: false}, {Name: "force_openai_fast", Type: field.TypeBool, Default: false}, + {Name: "free_openai_fast", Type: field.TypeBool, Default: false}, {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 364a571589..b67f2a7735 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22159,6 +22159,7 @@ type GroupMutation struct { allow_messages_dispatch *bool allow_live *bool force_openai_fast *bool + free_openai_fast *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24982,6 +24983,42 @@ func (m *GroupMutation) ResetForceOpenaiFast() { m.force_openai_fast = nil } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (m *GroupMutation) SetFreeOpenaiFast(b bool) { + m.free_openai_fast = &b +} + +// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation. +func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) { + v := m.free_openai_fast + if v == nil { + return + } + return *v, true +} + +// OldFreeOpenaiFast returns the old "free_openai_fast" field's value of the Group entity. +// If the Group 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 *GroupMutation) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err) + } + return oldValue.FreeOpenaiFast, nil +} + +// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field. +func (m *GroupMutation) ResetFreeOpenaiFast() { + m.free_openai_fast = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -25847,7 +25884,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 63) + fields := make([]string, 0, 64) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -26004,6 +26041,9 @@ func (m *GroupMutation) Fields() []string { if m.force_openai_fast != nil { fields = append(fields, group.FieldForceOpenaiFast) } + if m.free_openai_fast != nil { + fields = append(fields, group.FieldFreeOpenaiFast) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -26152,6 +26192,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AllowLive() case group.FieldForceOpenaiFast: return m.ForceOpenaiFast() + case group.FieldFreeOpenaiFast: + return m.FreeOpenaiFast() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -26289,6 +26331,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAllowLive(ctx) case group.FieldForceOpenaiFast: return m.OldForceOpenaiFast(ctx) + case group.FieldFreeOpenaiFast: + return m.OldFreeOpenaiFast(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -26686,6 +26730,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetForceOpenaiFast(v) return nil + case group.FieldFreeOpenaiFast: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetFreeOpenaiFast(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -27437,6 +27488,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldForceOpenaiFast: m.ResetForceOpenaiFast() return nil + case group.FieldFreeOpenaiFast: + m.ResetFreeOpenaiFast() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index fb3d977d9a..8b7fd8f763 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1161,34 +1161,38 @@ func init() { groupDescForceOpenaiFast := groupFields[48].Descriptor() // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) + // groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field. + groupDescFreeOpenaiFast := groupFields[49].Descriptor() + // group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field. + group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[49].Descriptor() + groupDescRequireOauthOnly := groupFields[50].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[50].Descriptor() + groupDescRequirePrivacySet := groupFields[51].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[51].Descriptor() + groupDescDefaultMappedModel := groupFields[52].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[52].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[53].Descriptor() + groupDescModelsListConfig := groupFields[54].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[54].Descriptor() + groupDescRpmLimit := groupFields[55].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[55].Descriptor() + groupDescMaxReasoningEffort := groupFields[56].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. @@ -1200,19 +1204,19 @@ func init() { // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[56].Descriptor() + groupDescReasoningEffortMappings := groupFields[57].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[57].Descriptor() + groupDescProfitControlEnabled := groupFields[58].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[58].Descriptor() + groupDescProfitMinMargin := groupFields[59].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[59].Descriptor() + groupDescProfitSafetyBuffer := groupFields[60].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index e7700d5b8d..f5840521c2 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -245,6 +245,9 @@ func (Group) Fields() []ent.Field { field.Bool("force_openai_fast"). Default(false). Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"), + field.Bool("free_openai_fast"). + Default(false). + Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"), field.Bool("require_oauth_only"). Default(false). Comment("仅允许非 apikey 类型账号关联到此分组"), diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 74254c0f11..de76931a34 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -148,6 +148,7 @@ type CreateGroupRequest struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -220,6 +221,7 @@ type UpdateGroupRequest struct { AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` ForceOpenAIFast *bool `json:"force_openai_fast"` + FreeOpenAIFast *bool `json:"free_openai_fast"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -557,6 +559,7 @@ func (h *GroupHandler) Create(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -688,6 +691,7 @@ func (h *GroupHandler) Update(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, diff --git a/backend/internal/handler/admin/group_handler_openai_fast_test.go b/backend/internal/handler/admin/group_handler_openai_fast_test.go index 22a9a53500..54ff52f9f9 100644 --- a/backend/internal/handler/admin/group_handler_openai_fast_test.go +++ b/backend/internal/handler/admin/group_handler_openai_fast_test.go @@ -9,15 +9,19 @@ import ( func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) { var createReq CreateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true}`), &createReq)) + require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq)) require.True(t, createReq.ForceOpenAIFast) + require.True(t, createReq.FreeOpenAIFast) var updateReq UpdateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false}`), &updateReq)) + require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq)) require.NotNil(t, updateReq.ForceOpenAIFast) require.False(t, *updateReq.ForceOpenAIFast) + require.NotNil(t, updateReq.FreeOpenAIFast) + require.False(t, *updateReq.FreeOpenAIFast) var omitted UpdateGroupRequest require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) require.Nil(t, omitted.ForceOpenAIFast) + require.Nil(t, omitted.FreeOpenAIFast) } diff --git a/backend/internal/handler/dto/group_mapper_openai_fast_test.go b/backend/internal/handler/dto/group_mapper_openai_fast_test.go index c5aee931dc..6ad07b061f 100644 --- a/backend/internal/handler/dto/group_mapper_openai_fast_test.go +++ b/backend/internal/handler/dto/group_mapper_openai_fast_test.go @@ -10,14 +10,16 @@ import ( func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) { group := &service.Group{ - ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, + ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } userJSON, err := json.Marshal(GroupFromService(group)) require.NoError(t, err) require.NotContains(t, string(userJSON), "force_openai_fast") + require.NotContains(t, string(userJSON), "free_openai_fast") adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) require.NoError(t, err) require.Contains(t, string(adminJSON), `"force_openai_fast":true`) + require.Contains(t, string(adminJSON), `"free_openai_fast":true`) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index c8fa7aa241..e6759a110c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { out := &AdminGroup{ Group: groupFromServiceBase(g), ForceOpenAIFast: g.ForceOpenAIFast, + FreeOpenAIFast: g.FreeOpenAIFast, ProfitControlEnabled: g.ProfitControlEnabled, ProfitMinMargin: g.ProfitMinMargin, ProfitSafetyBuffer: g.ProfitSafetyBuffer, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 523711c612..90f565e434 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -168,6 +168,8 @@ type AdminGroup struct { Group // ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。 ForceOpenAIFast bool `json:"force_openai_fast"` + // FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。 + FreeOpenAIFast bool `json:"free_openai_fast"` // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 36b5258460..20af0ad26c 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -211,6 +211,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, + group.FieldFreeOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -1010,6 +1011,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { AllowMessagesDispatch: g.AllowMessagesDispatch, AllowLive: g.AllowLive, ForceOpenAIFast: g.ForceOpenaiFast, + FreeOpenAIFast: g.FreeOpenaiFast, RequireOAuthOnly: g.RequireOauthOnly, RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, diff --git a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go index a3e00a541d..f3687c3070 100644 --- a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go +++ b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go @@ -17,7 +17,7 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { suffix := time.Now().UnixNano() group := mustCreateGroup(t, integrationEntClient, &service.Group{ Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI, - RateMultiplier: 1, ForceOpenAIFast: true, + RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) user := mustCreateUser(t, integrationEntClient, &service.User{ Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5, @@ -42,4 +42,5 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { require.NoError(t, err) require.NotNil(t, got.Group) require.True(t, got.Group.ForceOpenAIFast) + require.True(t, got.Group.FreeOpenAIFast) } diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 987881f143..005ec7d0ba 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -91,6 +91,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetRateMultiplier(g.RateMultiplier). SetIsExclusive(g.IsExclusive). SetForceOpenaiFast(g.ForceOpenAIFast). + SetFreeOpenaiFast(g.FreeOpenAIFast). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 1b11f76a19..d70d8ce89c 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -104,6 +104,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -282,6 +283,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 4d32cce370..57f06fd2a8 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -58,6 +58,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。 requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false) + requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false) // api_keys: key length should be 128 requireColumn(t, tx, "api_keys", "key", "character varying", 128, false) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index c8eecdb2e6..8fcb6dd83b 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -303,6 +303,7 @@ func sanitizeGroupOpenAIFast(group *Group) { if group == nil || !groupSupportsOpenAIFast(group.Platform) { if group != nil { group.ForceOpenAIFast = false + group.FreeOpenAIFast = false } } } @@ -517,6 +518,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, ForceOpenAIFast: input.ForceOpenAIFast, + FreeOpenAIFast: input.FreeOpenAIFast, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -884,6 +886,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.ForceOpenAIFast != nil { group.ForceOpenAIFast = *input.ForceOpenAIFast } + if input.FreeOpenAIFast != nil { + group.FreeOpenAIFast = *input.FreeOpenAIFast + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index f2c6bcc464..b168f7773f 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -145,6 +145,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { AllowMessagesDispatch: source.AllowMessagesDispatch, AllowLive: source.AllowLive, ForceOpenAIFast: source.ForceOpenAIFast, + FreeOpenAIFast: source.FreeOpenAIFast, RequireOAuthOnly: source.RequireOAuthOnly, RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 4d74bd3e2b..75fd8ea173 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -167,6 +167,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, + FreeOpenAIFast: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", @@ -215,6 +216,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.ModelRouting, duplicate.ModelRouting) require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast) + require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast) require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 8fc7bb7514..392f8d5d46 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -267,6 +267,7 @@ type CreateGroupInput struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool + FreeOpenAIFast bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -345,6 +346,7 @@ type UpdateGroupInput struct { AllowMessagesDispatch *bool AllowLive *bool ForceOpenAIFast *bool + FreeOpenAIFast *bool DefaultMappedModel *string RequireOAuthOnly *bool RequirePrivacySet *bool diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 45f32caa38..aa1f08f560 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1099,19 +1099,20 @@ func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing svc := &adminServiceImpl{groupRepo: repo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, + Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) require.NoError(t, err) require.NotNil(t, group) require.Equal(t, tt.want, repo.created.ForceOpenAIFast) + require.Equal(t, tt.want, repo.created.FreeOpenAIFast) }) } } func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) { existingGroup := &Group{ - ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } repo := &groupRepoStubForAdmin{getByID: existingGroup} svc := &adminServiceImpl{groupRepo: repo} @@ -1123,6 +1124,7 @@ func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *te require.NoError(t, err) require.NotNil(t, group) require.False(t, repo.updated.ForceOpenAIFast) + require.False(t, repo.updated.FreeOpenAIFast) } func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) { @@ -1136,11 +1138,13 @@ func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ ForceOpenAIFast: &enabled, + FreeOpenAIFast: &enabled, }) require.NoError(t, err) require.NotNil(t, group) require.True(t, repo.updated.ForceOpenAIFast) + require.True(t, repo.updated.FreeOpenAIFast) require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 4c6caa4767..59d2774cd7 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -104,6 +104,7 @@ type APIKeyAuthGroupSnapshot struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 7fc903cb61..656a3ba6e7 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 21 // v21: group force_openai_fast field +const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field type apiKeyAuthCacheConfig struct { l1Size int @@ -419,6 +419,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, AllowLive: apiKey.Group.AllowLive, ForceOpenAIFast: apiKey.Group.ForceOpenAIFast, + FreeOpenAIFast: apiKey.Group.FreeOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -519,6 +520,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, AllowLive: snapshot.Group.AllowLive, ForceOpenAIFast: snapshot.Group.ForceOpenAIFast, + FreeOpenAIFast: snapshot.Group.FreeOpenAIFast, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go index 18a1d3ca9d..07e906e4e7 100644 --- a/backend/internal/service/api_key_auth_cache_openai_fast_test.go +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -15,7 +15,7 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { User: &User{ID: 40, Status: StatusActive}, Group: &Group{ ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, - Hydrated: true, ForceOpenAIFast: true, + Hydrated: true, ForceOpenAIFast: true, FreeOpenAIFast: true, }, } svc := &APIKeyService{} @@ -31,5 +31,6 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { require.NotNil(t, materialized.Group) require.True(t, materialized.Group.Hydrated) require.True(t, materialized.Group.ForceOpenAIFast) - require.Equal(t, 21, cached.Snapshot.Version) + require.True(t, materialized.Group.FreeOpenAIFast) + require.Equal(t, 22, cached.Snapshot.Version) } diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index d69d21d869..51347e0eab 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 21, snapshot.Version, "v21 起认证快照携带分组 Fast 强制开关") + require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 9b4dc78275..fc9996da3b 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -101,6 +101,7 @@ type Group struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority + FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费 RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 459e668d95..1973bba56e 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3081,6 +3081,77 @@ func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract( require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) } +func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(77) + serviceTier := "priority" + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + inputPrice := 0.001 + outputPrice := 0.002 + fastMultiplier := 3.0 + apiKey := &APIKey{ + ID: 1020, + GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, RateMultiplier: 0.5, FreeOpenAIFast: true, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, + BillingMode: BillingModeToken, + InputPrice: &inputPrice, + OutputPrice: &outputPrice, + FastMultiplier: &fastMultiplier, + }}, + }, + } + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_free_fast", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens}, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2020}, + Account: &Account{ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12) + + standardTotal := float64(tokens.InputTokens)*inputPrice + float64(tokens.OutputTokens)*outputPrice + require.InDelta(t, standardTotal*fastMultiplier, usageRepo.lastLog.TotalCost, 1e-10) + require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10) +} + +func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) { + apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}} + + require.True(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "priority", + )) + require.False(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + "priority", + )) +} + func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6fb688353a..cb998b55fa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -132,6 +132,24 @@ func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { return timezone.Now() } +func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { + if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { + return false + } + if account == nil || !account.IsOpenAI() { + return false + } + if !groupSupportsOpenAIFast(apiKey.Group.Platform) { + return false + } + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + return true + default: + return false + } +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -277,6 +295,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } } + // Free Fast changes only the customer charge. Keep priority TotalCost and + // service_tier for upstream accounting, but evaluate ActualCost once more at + // the Standard tier using the same channel, peak, and long-context policy. + if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) { + standardCost, standardErr := s.calculateOpenAIRecordUsageCost( + ctx, + result, + apiKey, + billingModels, + multiplier, + imageMultiplier, + videoMultiplier, + baseMultiplier, + tokens, + "", + longContextBillingGate, + pricingAt, + ) + if standardErr != nil { + return standardErr + } + if cost != nil && standardCost != nil { + cost.ActualCost = standardCost.ActualCost + } + } + // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() billingType := BillingTypeBalance diff --git a/backend/migrations/233_group_free_openai_fast.sql b/backend/migrations/233_group_free_openai_fast.sql new file mode 100644 index 0000000000..ef84cd31ca --- /dev/null +++ b/backend/migrations/233_group_free_openai_fast.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN groups.free_openai_fast IS + 'Whether Fast/priority requests in this OpenAI/Composite group are billed to users at Standard price'; diff --git a/backend/migrations/group_free_openai_fast_migration_test.go b/backend/migrations/group_free_openai_fast_migration_test.go new file mode 100644 index 0000000000..0943e0306b --- /dev/null +++ b/backend/migrations/group_free_openai_fast_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupFreeOpenAIFastMigration(t *testing.T) { + content, err := FS.ReadFile("233_group_free_openai_fast.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE") + require.Contains(t, sql, "COMMENT ON COLUMN groups.free_openai_fast") +} diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 5f136ad408..4f2f8df74e 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1158,7 +1158,9 @@ export default { openaiFast: { title: 'OpenAI Fast mode', force: 'Force Fast (priority)', - hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.' + hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.', + free: 'Free Fast', + freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.' }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index aabc4e33a3..7d2cb24c9b 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1156,7 +1156,9 @@ export default { openaiFast: { title: 'OpenAI Fast 模式', force: '强制使用 Fast(priority)', - hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。' + hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。', + free: '免费 Fast', + freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。' }, invalidRequestFallback: { title: '无效请求兜底分组', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 536b98ff1f..8fd0673f13 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -613,6 +613,7 @@ export interface Group { export interface AdminGroup extends Group { force_openai_fast: boolean + free_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 @@ -778,6 +779,7 @@ export interface CreateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean @@ -842,6 +844,7 @@ export interface UpdateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 3d9f295717..e60c8768ce 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1619,6 +1619,35 @@
{{ t("admin.groups.openaiFast.hint") }}
++ {{ t("admin.groups.openaiFast.freeHint") }} +
@@ -3387,6 +3416,35 @@{{ t("admin.groups.openaiFast.hint") }}
++ {{ t("admin.groups.openaiFast.freeHint") }} +
@@ -5137,6 +5195,7 @@ const createForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5500,6 +5559,7 @@ const editForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5976,6 +6036,7 @@ const closeCreateModal = () => { createForm.video_model_prices = createVideoModelPricesForm(); createForm.long_context_pricing_enabled = true; createForm.force_openai_fast = false; + createForm.free_openai_fast = false; createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; @@ -6081,6 +6142,10 @@ const handleCreateGroup = async () => { createForm.platform, createForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + createForm.platform, + createForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( createForm.model_pricing, createForm.platform, @@ -6210,6 +6275,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.long_context_pricing_enabled = group.long_context_pricing_enabled ?? true; editForm.force_openai_fast = group.force_openai_fast ?? false; + editForm.free_openai_fast = group.free_openai_fast ?? false; editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = @@ -6321,6 +6387,7 @@ const closeEditModal = () => { editForm.video_model_prices = createVideoModelPricesForm(); editForm.long_context_pricing_enabled = true; editForm.force_openai_fast = false; + editForm.free_openai_fast = false; editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null; @@ -6358,6 +6425,10 @@ const handleUpdateGroup = async () => { editForm.platform, editForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + editForm.platform, + editForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( editForm.model_pricing, editForm.platform, diff --git a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts index e94ee5a36c..a0333e0a5a 100644 --- a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts @@ -26,11 +26,15 @@ describe("groupsOpenAIFast", () => { title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); expect(en.groups.openaiFast).toMatchObject({ title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); }); });