diff --git a/backend/ent/group.go b/backend/ent/group.go index 8f30c26470..29912a2133 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -122,6 +122,8 @@ type Group struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"` // 是否允许此 OpenAI 分组访问 Live 接口 AllowLive bool `json:"allow_live,omitempty"` + // 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority + ForceOpenaiFast bool `json:"force_openai_fast,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -252,7 +254,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.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.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) @@ -616,6 +618,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.AllowLive = value.Bool } + case group.FieldForceOpenaiFast: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i]) + } else if value.Valid { + _m.ForceOpenaiFast = 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]) @@ -955,6 +963,9 @@ func (_m *Group) String() string { builder.WriteString("allow_live=") builder.WriteString(fmt.Sprintf("%v", _m.AllowLive)) builder.WriteString(", ") + builder.WriteString("force_openai_fast=") + builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast)) + 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 5ec04d32ae..87cdad3dff 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -118,6 +118,8 @@ const ( FieldAllowMessagesDispatch = "allow_messages_dispatch" // FieldAllowLive holds the string denoting the allow_live field in the database. FieldAllowLive = "allow_live" + // FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database. + FieldForceOpenaiFast = "force_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. @@ -266,6 +268,7 @@ var Columns = []string{ FieldSortOrder, FieldAllowMessagesDispatch, FieldAllowLive, + FieldForceOpenaiFast, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -386,6 +389,8 @@ var ( DefaultAllowMessagesDispatch bool // DefaultAllowLive holds the default value on creation for the "allow_live" field. DefaultAllowLive bool + // DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field. + DefaultForceOpenaiFast 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. @@ -657,6 +662,11 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAllowLive, opts...).ToFunc() } +// ByForceOpenaiFast orders the results by the force_openai_fast field. +func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldForceOpenaiFast, 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 685a48a922..357c6921b8 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -290,6 +290,11 @@ func AllowLive(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAllowLive, v)) } +// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ. +func ForceOpenaiFast(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, 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)) @@ -2270,6 +2275,16 @@ func AllowLiveNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldAllowLive, v)) } +// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field. +func ForceOpenaiFastEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) +} + +// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field. +func ForceOpenaiFastNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, 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 e5e92ccd3f..9e0a846ee5 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -704,6 +704,20 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate { return _c } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate { + _c.mutation.SetForceOpenaiFast(v) + return _c +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate { + if v != nil { + _c.SetForceOpenaiFast(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -1095,6 +1109,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultAllowLive _c.mutation.SetAllowLive(v) } + if _, ok := _c.mutation.ForceOpenaiFast(); !ok { + v := group.DefaultForceOpenaiFast + _c.mutation.SetForceOpenaiFast(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1286,6 +1304,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.AllowLive(); !ok { return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)} } + 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.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1560,6 +1581,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) _node.AllowLive = value } + if value, ok := _c.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) + _node.ForceOpenaiFast = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2618,6 +2643,18 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert { return u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert { + u.Set(group.FieldForceOpenaiFast, v) + return u +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert { + u.SetExcluded(group.FieldForceOpenaiFast) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3817,6 +3854,20 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne { }) } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetForceOpenaiFast(v) + }) +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateForceOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5207,6 +5258,20 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk { }) } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetForceOpenaiFast(v) + }) +} + +// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateForceOpenaiFast() + }) +} + // 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 41e06b4471..ed68bcacb9 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -989,6 +989,20 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate { return _u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate { + _u.mutation.SetForceOpenaiFast(v) + return _u +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate { + if v != nil { + _u.SetForceOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1797,6 +1811,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AllowLive(); ok { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) } + if value, ok := _u.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -3110,6 +3127,20 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne { return _u } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne { + _u.mutation.SetForceOpenaiFast(v) + return _u +} + +// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetForceOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -3948,6 +3979,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AllowLive(); ok { _spec.SetField(group.FieldAllowLive, field.TypeBool, value) } + if value, ok := _u.mutation.ForceOpenaiFast(); ok { + _spec.SetField(group.FieldForceOpenaiFast, 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 270eecd87a..8aa1cc7a69 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -954,6 +954,7 @@ var ( {Name: "sort_order", Type: field.TypeInt, Default: 0}, {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: "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 54d284ad97..6c22f3373c 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22158,6 +22158,7 @@ type GroupMutation struct { addsort_order *int allow_messages_dispatch *bool allow_live *bool + force_openai_fast *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24944,6 +24945,42 @@ func (m *GroupMutation) ResetAllowLive() { m.allow_live = nil } +// SetForceOpenaiFast sets the "force_openai_fast" field. +func (m *GroupMutation) SetForceOpenaiFast(b bool) { + m.force_openai_fast = &b +} + +// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation. +func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) { + v := m.force_openai_fast + if v == nil { + return + } + return *v, true +} + +// OldForceOpenaiFast returns the old "force_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) OldForceOpenaiFast(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err) + } + return oldValue.ForceOpenaiFast, nil +} + +// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field. +func (m *GroupMutation) ResetForceOpenaiFast() { + m.force_openai_fast = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -25773,7 +25810,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, 62) + fields := make([]string, 0, 63) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25927,6 +25964,9 @@ func (m *GroupMutation) Fields() []string { if m.allow_live != nil { fields = append(fields, group.FieldAllowLive) } + if m.force_openai_fast != nil { + fields = append(fields, group.FieldForceOpenaiFast) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -26070,6 +26110,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AllowMessagesDispatch() case group.FieldAllowLive: return m.AllowLive() + case group.FieldForceOpenaiFast: + return m.ForceOpenaiFast() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -26203,6 +26245,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAllowMessagesDispatch(ctx) case group.FieldAllowLive: return m.OldAllowLive(ctx) + case group.FieldForceOpenaiFast: + return m.OldForceOpenaiFast(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -26591,6 +26635,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetAllowLive(v) return nil + case group.FieldForceOpenaiFast: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetForceOpenaiFast(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -27332,6 +27383,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldAllowLive: m.ResetAllowLive() return nil + case group.FieldForceOpenaiFast: + m.ResetForceOpenaiFast() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 088b876b96..8323961ebd 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1157,52 +1157,56 @@ func init() { groupDescAllowLive := groupFields[47].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) + // groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field. + groupDescForceOpenaiFast := groupFields[48].Descriptor() + // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. + group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[48].Descriptor() + groupDescRequireOauthOnly := groupFields[49].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[49].Descriptor() + groupDescRequirePrivacySet := groupFields[50].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[50].Descriptor() + groupDescDefaultMappedModel := groupFields[51].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[51].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[52].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[52].Descriptor() + groupDescModelsListConfig := groupFields[53].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[53].Descriptor() + groupDescRpmLimit := groupFields[54].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[54].Descriptor() + groupDescMaxReasoningEffort := groupFields[55].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. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[55].Descriptor() + groupDescReasoningEffortMappings := groupFields[56].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[56].Descriptor() + groupDescProfitControlEnabled := groupFields[57].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[57].Descriptor() + groupDescProfitMinMargin := groupFields[58].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[58].Descriptor() + groupDescProfitSafetyBuffer := groupFields[59].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 f195ac51db..01efd0f5c3 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -242,6 +242,9 @@ func (Group) Fields() []ent.Field { field.Bool("allow_live"). Default(false). Comment("是否允许此 OpenAI 分组访问 Live 接口"), + field.Bool("force_openai_fast"). + Default(false). + Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"), 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 2fcd69a9b2..13f7d2fd8c 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -147,6 +147,7 @@ type CreateGroupRequest struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` + ForceOpenAIFast bool `json:"force_openai_fast"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -216,6 +217,7 @@ type UpdateGroupRequest struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` + ForceOpenAIFast *bool `json:"force_openai_fast"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -550,6 +552,7 @@ func (h *GroupHandler) Create(c *gin.Context) { SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, + ForceOpenAIFast: req.ForceOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -679,6 +682,7 @@ func (h *GroupHandler) Update(c *gin.Context) { SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, + ForceOpenAIFast: req.ForceOpenAIFast, 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 new file mode 100644 index 0000000000..22a9a53500 --- /dev/null +++ b/backend/internal/handler/admin/group_handler_openai_fast_test.go @@ -0,0 +1,23 @@ +package admin + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) { + var createReq CreateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true}`), &createReq)) + require.True(t, createReq.ForceOpenAIFast) + + var updateReq UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false}`), &updateReq)) + require.NotNil(t, updateReq.ForceOpenAIFast) + require.False(t, *updateReq.ForceOpenAIFast) + + var omitted UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) + require.Nil(t, omitted.ForceOpenAIFast) +} diff --git a/backend/internal/handler/dto/group_mapper_openai_fast_test.go b/backend/internal/handler/dto/group_mapper_openai_fast_test.go new file mode 100644 index 0000000000..c5aee931dc --- /dev/null +++ b/backend/internal/handler/dto/group_mapper_openai_fast_test.go @@ -0,0 +1,23 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) { + group := &service.Group{ + ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, + } + + userJSON, err := json.Marshal(GroupFromService(group)) + require.NoError(t, err) + require.NotContains(t, string(userJSON), "force_openai_fast") + + adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) + require.NoError(t, err) + require.Contains(t, string(adminJSON), `"force_openai_fast":true`) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1c02cd69a3..63aa423cbb 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -148,6 +148,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { } out := &AdminGroup{ Group: groupFromServiceBase(g), + ForceOpenAIFast: g.ForceOpenAIFast, 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 5ac667e171..f07592c2ca 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -164,6 +164,8 @@ type Group struct { // 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。 type AdminGroup struct { Group + // ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。 + ForceOpenAIFast bool `json:"force_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 6e517f4524..f1bd5c53d0 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -210,6 +210,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldSupportedModelScopes, group.FieldAllowMessagesDispatch, group.FieldAllowLive, + group.FieldForceOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -1007,6 +1008,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { SortOrder: g.SortOrder, AllowMessagesDispatch: g.AllowMessagesDispatch, AllowLive: g.AllowLive, + ForceOpenAIFast: g.ForceOpenaiFast, 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 new file mode 100644 index 0000000000..a3e00a541d --- /dev/null +++ b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { + ctx := context.Background() + 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, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-fast-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "fast-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group) + require.True(t, got.Group.ForceOpenAIFast) +} diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index ca927be265..987881f143 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -90,6 +90,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetSubscriptionType(g.SubscriptionType). SetRateMultiplier(g.RateMultiplier). SetIsExclusive(g.IsExclusive). + SetForceOpenaiFast(g.ForceOpenAIFast). 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 b5de46353d..125456cd59 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -103,6 +103,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). + SetForceOpenaiFast(groupIn.ForceOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -279,6 +280,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). + SetForceOpenaiFast(groupIn.ForceOpenAIFast). 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 d90cc4f4b5..4d32cce370 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -55,8 +55,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true) requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due") - // groups: OpenAI Live 默认关闭,管理员显式开启后才可访问。 + // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。 requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) + requireColumn(t, tx, "groups", "force_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 9e29c30eaa..032c7024bc 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -295,6 +295,18 @@ func groupSupportsOAuthOnlyFilter(platform string) bool { platform == PlatformComposite } +func groupSupportsOpenAIFast(platform string) bool { + return platform == PlatformOpenAI || platform == PlatformComposite +} + +func sanitizeGroupOpenAIFast(group *Group) { + if group == nil || !groupSupportsOpenAIFast(group.Platform) { + if group != nil { + group.ForceOpenAIFast = false + } + } +} + func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") @@ -500,6 +512,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn SupportedModelScopes: input.SupportedModelScopes, AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, + ForceOpenAIFast: input.ForceOpenAIFast, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -510,6 +523,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } @@ -862,6 +876,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.AllowLive != nil { group.AllowLive = *input.AllowLive } + if input.ForceOpenAIFast != nil { + group.ForceOpenAIFast = *input.ForceOpenAIFast + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } @@ -895,6 +912,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index deda6d3822..09fcbb8ec7 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -144,6 +144,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { SortOrder: source.SortOrder, AllowMessagesDispatch: source.AllowMessagesDispatch, AllowLive: source.AllowLive, + ForceOpenAIFast: source.ForceOpenAIFast, 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 f4a7bef2c5..4a13c53fce 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -166,6 +166,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing SortOrder: 9, AllowMessagesDispatch: true, AllowLive: true, + ForceOpenAIFast: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", @@ -212,6 +213,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID) 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.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 e48eea51c1..0195bc01c7 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -266,6 +266,7 @@ type CreateGroupInput struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool AllowLive bool + ForceOpenAIFast bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -341,6 +342,7 @@ type UpdateGroupInput struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool AllowLive *bool + ForceOpenAIFast *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 72c5e9a0f7..02694cdfde 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1082,6 +1082,66 @@ func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) { require.True(t, repo.created.AllowLive) } +func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing.T) { + for _, tt := range []struct { + name string + platform string + want bool + }{ + {name: "openai", platform: PlatformOpenAI, want: true}, + {name: "composite", platform: PlatformComposite, want: true}, + {name: "anthropic", platform: PlatformAnthropic, want: false}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.Equal(t, tt.want, repo.created.ForceOpenAIFast) + }) + } +} + +func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) { + existingGroup := &Group{ + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + Platform: PlatformAnthropic, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.False(t, repo.updated.ForceOpenAIFast) +} + +func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) { + existingGroup := &Group{ + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + invalidator := &authCacheInvalidatorStub{} + svc := &adminServiceImpl{groupRepo: repo, authCacheInvalidator: invalidator} + enabled := true + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + ForceOpenAIFast: &enabled, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.True(t, repo.updated.ForceOpenAIFast) + require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs) +} + func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 71e0318dc0..0bdbfc8293 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -103,6 +103,7 @@ type APIKeyAuthGroupSnapshot struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` + ForceOpenAIFast bool `json:"force_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 3c95141a47..f267a3fe89 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 = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots) +const apiKeyAuthSnapshotVersion = 21 // v21: group force_openai_fast field type apiKeyAuthCacheConfig struct { l1Size int @@ -418,6 +418,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) SupportedModelScopes: apiKey.Group.SupportedModelScopes, AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, AllowLive: apiKey.Group.AllowLive, + ForceOpenAIFast: apiKey.Group.ForceOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -516,6 +517,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho SupportedModelScopes: snapshot.Group.SupportedModelScopes, AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, AllowLive: snapshot.Group.AllowLive, + ForceOpenAIFast: snapshot.Group.ForceOpenAIFast, 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 new file mode 100644 index 0000000000..18a1d3ca9d --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { + groupID := int64(50) + apiKey := &APIKey{ + ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-fast-roundtrip", Status: StatusActive, + User: &User{ID: 40, Status: StatusActive}, + Group: &Group{ + ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, ForceOpenAIFast: true, + }, + } + svc := &APIKeyService{} + + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)}) + require.NoError(t, err) + var cached APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &cached)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.Hydrated) + require.True(t, materialized.Group.ForceOpenAIFast) + require.Equal(t, 21, 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 fbcd96a42a..d69d21d869 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, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段") + require.Equal(t, 21, snapshot.Version, "v21 起认证快照携带分组 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 bf0440afc3..8703057377 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -100,6 +100,7 @@ type Group struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool AllowLive bool + ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index c5144b6a02..23486ba2f8 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -36,7 +36,13 @@ func (s *openAIFastPolicyRepoStub) Set(ctx context.Context, key, value string) e } func (s *openAIFastPolicyRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { - panic("unexpected GetMultiple call") + result := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + result[key] = value + } + } + return result, nil } func (s *openAIFastPolicyRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { @@ -262,6 +268,85 @@ func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T } } +func TestApplyOpenAIFastPolicyToBody_GroupForceInjectsAndOverridesTier(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + for _, body := range [][]byte{ + []byte(`{"model":"gpt-5.6-sol","input":"hi"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"default"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"flex"}`), + []byte(`{"model":"gpt-5.6-sol","service_tier":"client-unknown"}`), + } { + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) + } +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceStillHonorsGlobalPolicy(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", []byte(`{"model":"gpt-5.6-sol"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(updated, "service_tier").Exists(), + "the global filter remains authoritative after the group requests priority") +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresHydratedGroup(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + body := []byte(`{"model":"gpt-5.6-sol"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceOnlyTargetsOpenAIAccounts(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + body := []byte(`{"model":"grok-4.1"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformComposite, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody( + ctx, + &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, + "grok-4.1", + body, + ) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + +func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresSupportedGroupPlatform(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + body := []byte(`{"model":"gpt-5.6-sol"}`) + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + updated, err := svc.applyOpenAIFastPolicyToBody( + ctx, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "gpt-5.6-sol", + body, + ) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + // TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule 验证默认配置 // 下客户端显式发送的 OpenAI 官方合法 tier 能透传到上游而不被静默剥离。 func TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule(t *testing.T) { diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index 195bdd459c..607d32a22a 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -122,6 +122,34 @@ func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) { } } +func TestWSResponseCreate_GroupForceInjectsMissingTier(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + frame := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) +} + +func TestWSResponseCreate_GroupForceDoesNotTouchOtherFrames(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + frame := []byte(`{"type":"response.cancel","model":"gpt-5.6-sol"}`) + + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, string(frame), string(updated)) +} + func TestWSResponseCreate_FlexPassThrough(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 6afca5179a..10232829fa 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -305,6 +305,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, policyErr } responsesBody = updatedBody + responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) // 5. Get access token token, _, err := s.GetAccessToken(ctx, account) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 875dd3ae8b..18b5aff28e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -625,7 +625,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } - if rawTier := requestView.ServiceTier; rawTier != "" { + rawTier := requestView.ServiceTier + if openAIGroupForcesFast(ctx, account) { + rawTier = OpenAIFastTierPriority + if requestView.ServiceTier != OpenAIFastTierPriority { + markPatchSet("service_tier", OpenAIFastTierPriority) + } + } + if rawTier != "" { if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" { action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier) switch action { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index cfd0649154..68110ab493 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -291,6 +291,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, policyErr } responsesBody = updatedBody + responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) grokCacheIdentity := "" if account.Platform == PlatformGrok { grokIntentBody := responsesBody diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index be89cdbc07..46f69b54d6 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1638,11 +1638,23 @@ func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicyS return nil } +func openAIGroupForcesFast(ctx context.Context, account *Account) bool { + if ctx == nil || account == nil || account.Platform != PlatformOpenAI { + return false + } + group, _ := ctx.Value(ctxkey.Group).(*Group) + return IsGroupContextValid(group) && groupSupportsOpenAIFast(group.Platform) && group.ForceOpenAIFast +} + // applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request // body. When action=filter it removes the service_tier field; when // action=block it returns (body, *OpenAIFastBlockedError). On pass it // normalizes the service_tier value (e.g. client alias "fast" → "priority"). -// action=force_priority rewrites any matched known tier to "priority". +// action=force_priority rewrites any matched known tier to "priority". Before +// the global policy is evaluated, a trusted request Group with +// ForceOpenAIFast enabled unconditionally sets service_tier to "priority". +// The global policy remains authoritative and may still pass, filter, or block +// that final value. // // Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 // 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 @@ -1653,6 +1665,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, if len(body) == 0 { return body, nil } + if openAIGroupForcesFast(ctx, account) { + updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) + if err != nil { + return body, fmt.Errorf("force group service_tier priority on body: %w", err) + } + body = updated + } rawTier := gjson.GetBytes(body, "service_tier").String() if rawTier == "" { return body, nil @@ -1726,6 +1745,7 @@ func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlocked // - filter: returns a copy with top-level service_tier removed // - force_priority: keeps service_tier and rewrites it to "priority" // - block: returns (frame, *OpenAIFastBlockedError) +// - Group ForceOpenAIFast: sets priority first, then applies the global rule // // Only frames whose "type" field strictly equals "response.create" are // inspected/mutated. Any other frame type — including the empty string — @@ -1764,6 +1784,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( if frameType != "response.create" { return frame, nil, nil } + if openAIGroupForcesFast(ctx, account) { + updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) + if err != nil { + return frame, nil, fmt.Errorf("force group service_tier priority in ws frame: %w", err) + } + frame = updated + } rawTier := gjson.GetBytes(frame, "service_tier").String() if rawTier == "" { return frame, nil, nil diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 121a768790..36171ae72d 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" @@ -473,6 +474,38 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali require.NotContains(t, body, "\"name\":\"edit\"") } +func TestOpenAIGatewayService_OAuthPassthrough_GroupForceOpenAIFastInjectsMissingTier(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"test","input":"hi"}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 123, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, + RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, OpenAIFastTierPriority, *result.ServiceTier) +} + // 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、 // namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉 // 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。 @@ -948,6 +981,73 @@ func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *te require.Contains(t, string(upstream.lastBody), `"stream":true`) } +func TestOpenAIGatewayService_OAuthLegacy_GroupForceOpenAIFastInjectsMissingTier(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") + inputBody := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"text","text":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_group_fast_legacy"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) + svc.cfg = &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}} + svc.httpUpstream = upstream + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": false, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, inputBody) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, OpenAIFastTierPriority, *result.ServiceTier) +} + +func TestOpenAIGatewayService_OAuthLegacy_GroupForceStillHonorsGlobalFilter(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") + inputBody := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"text","text":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_group_fast_filter"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + svc.cfg = &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}} + svc.httpUpstream = upstream + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": false, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{ + ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true, + }) + + result, err := svc.Forward(ctx, c, account, inputBody) + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists()) + require.Nil(t, result.ServiceTier) +} + func TestOpenAIGatewayService_OAuthLegacy_UpstreamRequestIgnoresClientCancel(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/migrations/232_group_force_openai_fast.sql b/backend/migrations/232_group_force_openai_fast.sql new file mode 100644 index 0000000000..aaf0f4f4fa --- /dev/null +++ b/backend/migrations/232_group_force_openai_fast.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS force_openai_fast BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN groups.force_openai_fast IS + 'Force OpenAI gateway requests in this group to use service_tier=priority before global Fast/Flex policy evaluation'; diff --git a/backend/migrations/group_force_openai_fast_migration_test.go b/backend/migrations/group_force_openai_fast_migration_test.go new file mode 100644 index 0000000000..100c1294ab --- /dev/null +++ b/backend/migrations/group_force_openai_fast_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupForceOpenAIFastMigration(t *testing.T) { + content, err := FS.ReadFile("232_group_force_openai_fast.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "ADD COLUMN IF NOT EXISTS force_openai_fast BOOLEAN NOT NULL DEFAULT FALSE") + require.Contains(t, sql, "COMMENT ON COLUMN groups.force_openai_fast") +} diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index e91233132d..41a7781c8c 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1151,6 +1151,11 @@ export default { unsupportedMessage: 'This Sub2API server cannot generate the required Live attestation. Live will not work even if enabled. Continue anyway?', enableAnyway: 'Enable anyway' }, + 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.' + }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index cbc930fa10..27cf6fdfd7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1149,6 +1149,11 @@ export default { unsupportedMessage: '当前 Sub2API 服务端无法生成 Live 所需的设备证明,即使开启也不能使用。是否仍然开启?', enableAnyway: '仍然开启' }, + openaiFast: { + title: 'OpenAI Fast 模式', + force: '强制使用 Fast(priority)', + hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。' + }, invalidRequestFallback: { title: '无效请求兜底分组', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0a1b7183d8..ae86a3be2c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -611,6 +611,7 @@ export interface Group { } export interface AdminGroup extends Group { + force_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 @@ -775,6 +776,7 @@ export interface CreateGroupRequest { weekly_limit_usd?: number | null monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean + force_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean @@ -837,6 +839,7 @@ export interface UpdateGroupRequest { weekly_limit_usd?: number | null monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean + force_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 19e901e3d3..2cef5c8247 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1581,6 +1581,45 @@ + +
+ {{ t("admin.groups.openaiFast.hint") }} +
++ {{ t("admin.groups.openaiFast.hint") }} +
+