diff --git a/backend/ent/group.go b/backend/ent/group.go index e6ffdec453..4cff508698 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -117,6 +117,10 @@ type Group struct { ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"` // 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流 RpmLimit int `json:"rpm_limit,omitempty"` + // OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max + MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` + // OpenAI reasoning effort 自定义精确映射;先映射再应用上限 + ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -223,7 +227,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig: + case 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.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) @@ -231,7 +235,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: + case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -573,6 +577,20 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.RpmLimit = int(value.Int64) } + case group.FieldMaxReasoningEffort: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field max_reasoning_effort", values[i]) + } else if value.Valid { + _m.MaxReasoningEffort = value.String + } + case group.FieldReasoningEffortMappings: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.ReasoningEffortMappings); err != nil { + return fmt.Errorf("unmarshal field reasoning_effort_mappings: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -825,6 +843,12 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("rpm_limit=") builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit)) + builder.WriteString(", ") + builder.WriteString("max_reasoning_effort=") + builder.WriteString(_m.MaxReasoningEffort) + builder.WriteString(", ") + builder.WriteString("reasoning_effort_mappings=") + builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings)) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index efe2eaa066..cdf0bb9cd3 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -114,6 +114,10 @@ const ( FieldModelsListConfig = "models_list_config" // FieldRpmLimit holds the string denoting the rpm_limit field in the database. FieldRpmLimit = "rpm_limit" + // FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database. + FieldMaxReasoningEffort = "max_reasoning_effort" + // FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database. + FieldReasoningEffortMappings = "reasoning_effort_mappings" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -238,6 +242,8 @@ var Columns = []string{ FieldMessagesDispatchModelConfig, FieldModelsListConfig, FieldRpmLimit, + FieldMaxReasoningEffort, + FieldReasoningEffortMappings, } var ( @@ -349,6 +355,12 @@ var ( DefaultModelsListConfig domain.GroupModelsListConfig // DefaultRpmLimit holds the default value on creation for the "rpm_limit" field. DefaultRpmLimit int + // DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field. + DefaultMaxReasoningEffort string + // MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. + MaxReasoningEffortValidator func(string) error + // DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field. + DefaultReasoningEffortMappings []domain.ReasoningEffortMapping ) // OrderOption defines the ordering options for the Group queries. @@ -584,6 +596,11 @@ func ByRpmLimit(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRpmLimit, opts...).ToFunc() } +// ByMaxReasoningEffort orders the results by the max_reasoning_effort field. +func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 441326dba5..9320209433 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -280,6 +280,11 @@ func RpmLimit(v int) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRpmLimit, v)) } +// MaxReasoningEffort applies equality check predicate on the "max_reasoning_effort" field. It's identical to MaxReasoningEffortEQ. +func MaxReasoningEffort(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -2105,6 +2110,71 @@ func RpmLimitLTE(v int) predicate.Group { return predicate.Group(sql.FieldLTE(FieldRpmLimit, v)) } +// MaxReasoningEffortEQ applies the EQ predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortNEQ applies the NEQ predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortIn applies the In predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldMaxReasoningEffort, vs...)) +} + +// MaxReasoningEffortNotIn applies the NotIn predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffort, vs...)) +} + +// MaxReasoningEffortGT applies the GT predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortGTE applies the GTE predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortLT applies the LT predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortLTE applies the LTE predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortContains applies the Contains predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortEqualFold applies the EqualFold predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffort, v)) +} + +// MaxReasoningEffortContainsFold applies the ContainsFold predicate on the "max_reasoning_effort" field. +func MaxReasoningEffortContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 207b54fee0..bfd104bcee 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -691,6 +691,26 @@ func (_c *GroupCreate) SetNillableRpmLimit(v *int) *GroupCreate { return _c } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (_c *GroupCreate) SetMaxReasoningEffort(v string) *GroupCreate { + _c.mutation.SetMaxReasoningEffort(v) + return _c +} + +// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil. +func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate { + if v != nil { + _c.SetMaxReasoningEffort(*v) + } + return _c +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate { + _c.mutation.SetReasoningEffortMappings(v) + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -952,6 +972,14 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultRpmLimit _c.mutation.SetRpmLimit(v) } + if _, ok := _c.mutation.MaxReasoningEffort(); !ok { + v := group.DefaultMaxReasoningEffort + _c.mutation.SetMaxReasoningEffort(v) + } + if _, ok := _c.mutation.ReasoningEffortMappings(); !ok { + v := group.DefaultReasoningEffortMappings + _c.mutation.SetReasoningEffortMappings(v) + } return nil } @@ -1096,6 +1124,17 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.RpmLimit(); !ok { return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)} } + if _, ok := _c.mutation.MaxReasoningEffort(); !ok { + return &ValidationError{Name: "max_reasoning_effort", err: errors.New(`ent: missing required field "Group.max_reasoning_effort"`)} + } + if v, ok := _c.mutation.MaxReasoningEffort(); ok { + if err := group.MaxReasoningEffortValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} + } + } + if _, ok := _c.mutation.ReasoningEffortMappings(); !ok { + return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)} + } return nil } @@ -1319,6 +1358,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) _node.RpmLimit = value } + if value, ok := _c.mutation.MaxReasoningEffort(); ok { + _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) + _node.MaxReasoningEffort = value + } + if value, ok := _c.mutation.ReasoningEffortMappings(); ok { + _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) + _node.ReasoningEffortMappings = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2255,6 +2302,30 @@ func (u *GroupUpsert) AddRpmLimit(v int) *GroupUpsert { return u } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (u *GroupUpsert) SetMaxReasoningEffort(v string) *GroupUpsert { + u.Set(group.FieldMaxReasoningEffort, v) + return u +} + +// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create. +func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert { + u.SetExcluded(group.FieldMaxReasoningEffort) + return u +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert { + u.Set(group.FieldReasoningEffortMappings, v) + return u +} + +// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create. +func (u *GroupUpsert) UpdateReasoningEffortMappings() *GroupUpsert { + u.SetExcluded(group.FieldReasoningEffortMappings) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -3213,6 +3284,34 @@ func (u *GroupUpsertOne) UpdateRpmLimit() *GroupUpsertOne { }) } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (u *GroupUpsertOne) SetMaxReasoningEffort(v string) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetMaxReasoningEffort(v) + }) +} + +// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateMaxReasoningEffort() + }) +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetReasoningEffortMappings(v) + }) +} + +// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateReasoningEffortMappings() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateReasoningEffortMappings() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -4337,6 +4436,34 @@ func (u *GroupUpsertBulk) UpdateRpmLimit() *GroupUpsertBulk { }) } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (u *GroupUpsertBulk) SetMaxReasoningEffort(v string) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetMaxReasoningEffort(v) + }) +} + +// UpdateMaxReasoningEffort sets the "max_reasoning_effort" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateMaxReasoningEffort() + }) +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetReasoningEffortMappings(v) + }) +} + +// UpdateReasoningEffortMappings sets the "reasoning_effort_mappings" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateReasoningEffortMappings() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateReasoningEffortMappings() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 234e4fe96c..52383d7668 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -913,6 +913,32 @@ func (_u *GroupUpdate) AddRpmLimit(v int) *GroupUpdate { return _u } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (_u *GroupUpdate) SetMaxReasoningEffort(v string) *GroupUpdate { + _u.mutation.SetMaxReasoningEffort(v) + return _u +} + +// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate { + if v != nil { + _u.SetMaxReasoningEffort(*v) + } + return _u +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate { + _u.mutation.SetReasoningEffortMappings(v) + return _u +} + +// AppendReasoningEffortMappings appends value to the "reasoning_effort_mappings" field. +func (_u *GroupUpdate) AppendReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate { + _u.mutation.AppendReasoningEffortMappings(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -1213,6 +1239,11 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} } } + if v, ok := _u.mutation.MaxReasoningEffort(); ok { + if err := group.MaxReasoningEffortValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} + } + } return nil } @@ -1485,6 +1516,17 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedRpmLimit(); ok { _spec.AddField(group.FieldRpmLimit, field.TypeInt, value) } + if value, ok := _u.mutation.MaxReasoningEffort(); ok { + _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) + } + if value, ok := _u.mutation.ReasoningEffortMappings(); ok { + _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedReasoningEffortMappings(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, group.FieldReasoningEffortMappings, value) + }) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2676,6 +2718,32 @@ func (_u *GroupUpdateOne) AddRpmLimit(v int) *GroupUpdateOne { return _u } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (_u *GroupUpdateOne) SetMaxReasoningEffort(v string) *GroupUpdateOne { + _u.mutation.SetMaxReasoningEffort(v) + return _u +} + +// SetNillableMaxReasoningEffort sets the "max_reasoning_effort" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateOne { + if v != nil { + _u.SetMaxReasoningEffort(*v) + } + return _u +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne { + _u.mutation.SetReasoningEffortMappings(v) + return _u +} + +// AppendReasoningEffortMappings appends value to the "reasoning_effort_mappings" field. +func (_u *GroupUpdateOne) AppendReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne { + _u.mutation.AppendReasoningEffortMappings(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -2989,6 +3057,11 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} } } + if v, ok := _u.mutation.MaxReasoningEffort(); ok { + if err := group.MaxReasoningEffortValidator(v); err != nil { + return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)} + } + } return nil } @@ -3278,6 +3351,17 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedRpmLimit(); ok { _spec.AddField(group.FieldRpmLimit, field.TypeInt, value) } + if value, ok := _u.mutation.MaxReasoningEffort(); ok { + _spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value) + } + if value, ok := _u.mutation.ReasoningEffortMappings(); ok { + _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedReasoningEffortMappings(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, group.FieldReasoningEffortMappings, value) + }) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index c09f1f26ab..9d979e4c21 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -882,6 +882,8 @@ var ( {Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rpm_limit", Type: field.TypeInt, Default: 0}, + {Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""}, + {Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 9e746e19da..1e69df30fc 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -20865,6 +20865,9 @@ type GroupMutation struct { models_list_config *domain.GroupModelsListConfig rpm_limit *int addrpm_limit *int + max_reasoning_effort *string + reasoning_effort_mappings *[]domain.ReasoningEffortMapping + appendreasoning_effort_mappings []domain.ReasoningEffortMapping clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -23406,6 +23409,93 @@ func (m *GroupMutation) ResetRpmLimit() { m.addrpm_limit = nil } +// SetMaxReasoningEffort sets the "max_reasoning_effort" field. +func (m *GroupMutation) SetMaxReasoningEffort(s string) { + m.max_reasoning_effort = &s +} + +// MaxReasoningEffort returns the value of the "max_reasoning_effort" field in the mutation. +func (m *GroupMutation) MaxReasoningEffort() (r string, exists bool) { + v := m.max_reasoning_effort + if v == nil { + return + } + return *v, true +} + +// OldMaxReasoningEffort returns the old "max_reasoning_effort" 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) OldMaxReasoningEffort(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMaxReasoningEffort is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMaxReasoningEffort requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMaxReasoningEffort: %w", err) + } + return oldValue.MaxReasoningEffort, nil +} + +// ResetMaxReasoningEffort resets all changes to the "max_reasoning_effort" field. +func (m *GroupMutation) ResetMaxReasoningEffort() { + m.max_reasoning_effort = nil +} + +// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field. +func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) { + m.reasoning_effort_mappings = &dem + m.appendreasoning_effort_mappings = nil +} + +// ReasoningEffortMappings returns the value of the "reasoning_effort_mappings" field in the mutation. +func (m *GroupMutation) ReasoningEffortMappings() (r []domain.ReasoningEffortMapping, exists bool) { + v := m.reasoning_effort_mappings + if v == nil { + return + } + return *v, true +} + +// OldReasoningEffortMappings returns the old "reasoning_effort_mappings" 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) OldReasoningEffortMappings(ctx context.Context) (v []domain.ReasoningEffortMapping, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldReasoningEffortMappings is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldReasoningEffortMappings requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldReasoningEffortMappings: %w", err) + } + return oldValue.ReasoningEffortMappings, nil +} + +// AppendReasoningEffortMappings adds dem to the "reasoning_effort_mappings" field. +func (m *GroupMutation) AppendReasoningEffortMappings(dem []domain.ReasoningEffortMapping) { + m.appendreasoning_effort_mappings = append(m.appendreasoning_effort_mappings, dem...) +} + +// AppendedReasoningEffortMappings returns the list of values that were appended to the "reasoning_effort_mappings" field in this mutation. +func (m *GroupMutation) AppendedReasoningEffortMappings() ([]domain.ReasoningEffortMapping, bool) { + if len(m.appendreasoning_effort_mappings) == 0 { + return nil, false + } + return m.appendreasoning_effort_mappings, true +} + +// ResetReasoningEffortMappings resets all changes to the "reasoning_effort_mappings" field. +func (m *GroupMutation) ResetReasoningEffortMappings() { + m.reasoning_effort_mappings = nil + m.appendreasoning_effort_mappings = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -23764,7 +23854,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, 49) + fields := make([]string, 0, 51) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -23912,6 +24002,12 @@ func (m *GroupMutation) Fields() []string { if m.rpm_limit != nil { fields = append(fields, group.FieldRpmLimit) } + if m.max_reasoning_effort != nil { + fields = append(fields, group.FieldMaxReasoningEffort) + } + if m.reasoning_effort_mappings != nil { + fields = append(fields, group.FieldReasoningEffortMappings) + } return fields } @@ -24018,6 +24114,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.ModelsListConfig() case group.FieldRpmLimit: return m.RpmLimit() + case group.FieldMaxReasoningEffort: + return m.MaxReasoningEffort() + case group.FieldReasoningEffortMappings: + return m.ReasoningEffortMappings() } return nil, false } @@ -24125,6 +24225,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldModelsListConfig(ctx) case group.FieldRpmLimit: return m.OldRpmLimit(ctx) + case group.FieldMaxReasoningEffort: + return m.OldMaxReasoningEffort(ctx) + case group.FieldReasoningEffortMappings: + return m.OldReasoningEffortMappings(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -24477,6 +24581,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetRpmLimit(v) return nil + case group.FieldMaxReasoningEffort: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMaxReasoningEffort(v) + return nil + case group.FieldReasoningEffortMappings: + v, ok := value.([]domain.ReasoningEffortMapping) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetReasoningEffortMappings(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -25027,6 +25145,12 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldRpmLimit: m.ResetRpmLimit() return nil + case group.FieldMaxReasoningEffort: + m.ResetMaxReasoningEffort() + return nil + case group.FieldReasoningEffortMappings: + m.ResetReasoningEffortMappings() + return nil } return fmt.Errorf("unknown Group field %s", name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 42dbff5dae..b1dcc8c585 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1097,6 +1097,16 @@ func init() { groupDescRpmLimit := groupFields[45].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[46].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[47].Descriptor() + // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. + group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields() _ = idempotencyrecordMixinFields0 diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 3252112523..bce09c1f8a 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -221,6 +221,16 @@ func (Group) Fields() []ent.Field { field.Int("rpm_limit"). Default(0). Comment("分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流"), + + // OpenAI/Codex 请求的推理强度上限(空字符串表示不限制)。 + field.String("max_reasoning_effort"). + MaxLen(20). + Default(""). + Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"), + field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}). + Default([]domain.ReasoningEffortMapping{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"), } } diff --git a/backend/internal/domain/reasoning_effort.go b/backend/internal/domain/reasoning_effort.go new file mode 100644 index 0000000000..095df21d5e --- /dev/null +++ b/backend/internal/domain/reasoning_effort.go @@ -0,0 +1,8 @@ +package domain + +// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort +// value to another before the group ceiling is applied. +type ReasoningEffortMapping struct { + From string `json:"from"` + To string `json:"to"` +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 0f44d55311..7545c14f5a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -132,6 +132,10 @@ type CreateGroupRequest struct { ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` + // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 + MaxReasoningEffort string `json:"max_reasoning_effort"` + // OpenAI/Codex 推理强度精确映射。 + ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -186,6 +190,10 @@ type UpdateGroupRequest struct { ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` + // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 + MaxReasoningEffort *string `json:"max_reasoning_effort"` + // nil 不修改,空数组清空,非空数组替换。 + ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -354,6 +362,8 @@ func (h *GroupHandler) Create(c *gin.Context) { MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, + MaxReasoningEffort: req.MaxReasoningEffort, + ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { @@ -470,6 +480,8 @@ func (h *GroupHandler) Update(c *gin.Context) { MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, + MaxReasoningEffort: req.MaxReasoningEffort, + ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { diff --git a/backend/internal/handler/admin/group_handler_reasoning_effort_test.go b/backend/internal/handler/admin/group_handler_reasoning_effort_test.go new file mode 100644 index 0000000000..9e9164ffe4 --- /dev/null +++ b/backend/internal/handler/admin/group_handler_reasoning_effort_test.go @@ -0,0 +1,32 @@ +package admin + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) { + t.Run("omitted means unchanged", func(t *testing.T) { + var req UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{}`), &req)) + require.Nil(t, req.ReasoningEffortMappings) + }) + + t.Run("empty array means clear", func(t *testing.T) { + var req UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[]}`), &req)) + require.NotNil(t, req.ReasoningEffortMappings) + require.Empty(t, *req.ReasoningEffortMappings) + }) + + t.Run("non empty array means replace", func(t *testing.T) { + var req UpdateGroupRequest + require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"xhigh"}]}`), &req)) + require.NotNil(t, req.ReasoningEffortMappings) + require.Len(t, *req.ReasoningEffortMappings, 1) + require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From) + require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To) + }) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 7aa12d7a40..6f82e29c06 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -207,6 +207,8 @@ func groupFromServiceBase(g *service.Group) Group { RequireOAuthOnly: g.RequireOAuthOnly, RequirePrivacySet: g.RequirePrivacySet, RPMLimit: g.RPMLimit, + MaxReasoningEffort: g.MaxReasoningEffort, + ReasoningEffortMappings: g.ReasoningEffortMappings, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 89d3bc5634..117091f410 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -138,6 +138,10 @@ type Group struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。 RPMLimit int `json:"rpm_limit"` + // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + MaxReasoningEffort string `json:"max_reasoning_effort"` + // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 + ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index e16962ba5d..101eb57867 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -75,6 +75,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { return } reqModel := modelResult.String() + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { + if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { + body = cappedBody + } + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 7b697d6d69..d8d2790fd3 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -253,6 +253,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { return } reqModel := modelResult.String() + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { + if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { + body = cappedBody + } + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { @@ -1466,7 +1471,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "invalid JSON payload") return } - reqModel := strings.TrimSpace(gjson.GetBytes(firstMessage, "model").String()) if reqModel == "" { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload") @@ -1727,9 +1731,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.Int("candidate_count", scheduleDecision.CandidateCount), ) + maxReasoningEffort := "" + var reasoningEffortMappings []service.ReasoningEffortMapping + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { + maxReasoningEffort = apiKey.Group.MaxReasoningEffort + reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings + } var requestPayloadHash string hooks := &service.OpenAIWSIngressHooks{ - InitialRequestModel: reqModel, + InitialRequestModel: reqModel, + MaxReasoningEffort: maxReasoningEffort, + ReasoningEffortMappings: reasoningEffortMappings, BeforeRequest: func(turn int, payload []byte, originalModel string) error { if turn == 1 { return nil diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index c926a59f33..8addad4342 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -203,6 +203,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldRpmLimit, + group.FieldMaxReasoningEffort, + group.FieldReasoningEffortMappings, group.FieldPeakRateEnabled, group.FieldPeakStart, group.FieldPeakEnd, @@ -952,6 +954,8 @@ func groupEntityToService(g *dbent.Group) *service.Group { MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, ModelsListConfig: g.ModelsListConfig, RPMLimit: g.RpmLimit, + MaxReasoningEffort: g.MaxReasoningEffort, + ReasoningEffortMappings: g.ReasoningEffortMappings, PeakRateEnabled: g.PeakRateEnabled, PeakStart: g.PeakStart, PeakEnd: g.PeakEnd, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index c749ec47b5..2b770ffd7f 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -96,6 +96,8 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). SetRpmLimit(groupIn.RPMLimit). + SetMaxReasoningEffort(groupIn.MaxReasoningEffort). + SetReasoningEffortMappings(groupIn.ReasoningEffortMappings). SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). @@ -259,6 +261,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). SetRpmLimit(groupIn.RPMLimit). + SetMaxReasoningEffort(groupIn.MaxReasoningEffort). + SetReasoningEffortMappings(groupIn.ReasoningEffortMappings). SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index d622d547b0..f4e7f39a42 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "time" @@ -138,6 +139,14 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if platform == "" { platform = PlatformAnthropic } + maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(platform, input.MaxReasoningEffort) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) + } + reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err) + } subscriptionType := input.SubscriptionType if subscriptionType == "" { @@ -302,8 +311,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), RPMLimit: input.RPMLimit, + MaxReasoningEffort: maxReasoningEffort, + ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err } @@ -618,7 +630,22 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.RPMLimit != nil { group.RPMLimit = *input.RPMLimit } + if input.MaxReasoningEffort != nil { + maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(group.Platform, *input.MaxReasoningEffort) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) + } + group.MaxReasoningEffort = maxReasoningEffort + } + if input.ReasoningEffortMappings != nil { + reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err) + } + group.ReasoningEffortMappings = reasoningEffortMappings + } sanitizeGroupMessagesDispatchFields(group) + sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index ef025e3c86..6c57df1cf0 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -128,7 +128,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { Enabled: source.ModelsListConfig.Enabled, Models: append([]string(nil), source.ModelsListConfig.Models...), }, - RPMLimit: source.RPMLimit, + RPMLimit: source.RPMLimit, + MaxReasoningEffort: source.MaxReasoningEffort, + ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...), } } @@ -177,6 +179,7 @@ func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorSc } duplicate := cloneGroupForDuplicate(source, duplicateGroupOperationID(id, actorScope, operationKey)) + sanitizeGroupReasoningEffortPolicy(duplicate) for copyNumber := 1; ; copyNumber++ { duplicate.Name = duplicateGroupName(source.Name, copyNumber) duplicate.ID = 0 diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 1d47cd699f..c6c48d1e1f 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -173,6 +173,8 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing }, ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, RPMLimit: 99, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, CreatedAt: createdAt, UpdatedAt: createdAt, AccountCount: 12, @@ -207,6 +209,8 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) + require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) + require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings) require.EqualValues(t, 2, duplicate.AccountCount) require.EqualValues(t, 2, duplicate.ActiveAccountCount) require.NotEmpty(t, duplicate.DuplicateOperationID) @@ -220,11 +224,13 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing duplicate.SupportedModelScopes[0] = "changed" duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed" duplicate.ModelsListConfig.Models[0] = "changed" + duplicate.ReasoningEffortMappings[0].To = "changed" *duplicate.DailyLimitUSD = 999 require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0]) require.Equal(t, "claude", source.SupportedModelScopes[0]) require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"]) require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0]) + require.Equal(t, "xhigh", source.ReasoningEffortMappings[0].To) require.Equal(t, 11.0, *source.DailyLimitUSD) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 023c6acc1f..b6ad7b5279 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -254,6 +254,10 @@ type CreateGroupInput struct { ModelsListConfig GroupModelsListConfig // RPMLimit 分组 RPM 上限(0 = 不限制) RPMLimit int + // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + MaxReasoningEffort string + // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 + ReasoningEffortMappings []ReasoningEffortMapping // 从指定分组复制账号(创建分组后在同一事务内绑定) CopyAccountsFromGroupIDs []int64 } @@ -310,6 +314,10 @@ type UpdateGroupInput struct { ModelsListConfig *GroupModelsListConfig // RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。 RPMLimit *int + // MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。 + MaxReasoningEffort *string + // ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。 + ReasoningEffortMappings *[]ReasoningEffortMapping // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 7731b33bb6..5a6dccc115 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -658,6 +658,99 @@ func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testin require.Equal(t, []int64{1}, invalidator.groupIDs, "分组 RPMLimit 写入 auth snapshot,变更后必须失效 API Key 认证缓存") } +func TestAdminService_UpdateGroup_ReasoningEffortMappingsTriState(t *testing.T) { + tests := []struct { + name string + input *UpdateGroupInput + want []ReasoningEffortMapping + }{ + { + name: "nil preserves existing mappings", + input: &UpdateGroupInput{}, + want: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, + }, + { + name: "empty array clears mappings", + input: func() *UpdateGroupInput { + empty := []ReasoningEffortMapping{} + return &UpdateGroupInput{ReasoningEffortMappings: &empty} + }(), + want: []ReasoningEffortMapping{}, + }, + { + name: "non empty array replaces and canonicalizes mappings", + input: func() *UpdateGroupInput { + replacement := []ReasoningEffortMapping{{From: " X-HIGH ", To: " high "}} + return &UpdateGroupInput{ReasoningEffortMappings: &replacement} + }(), + want: []ReasoningEffortMapping{{From: "xhigh", To: "high"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + existing := &Group{ + ID: 1, + Name: "openai-group", + Platform: PlatformOpenAI, + Status: StatusActive, + ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, + } + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, tt.input) + + require.NoError(t, err) + require.Equal(t, tt.want, repo.updated.ReasoningEffortMappings) + }) + } +} + +func TestAdminService_UpdateGroup_RejectsInvalidReasoningEffortMappings(t *testing.T) { + existing := &Group{ + ID: 1, + Name: "openai", + Platform: PlatformOpenAI, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + Status: StatusActive, + } + repo := &groupRepoStubForInvalidRequestFallback{groups: map[int64]*Group{existing.ID: existing}} + svc := &adminServiceImpl{groupRepo: repo} + invalid := []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + {From: " MAX ", To: "high"}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + ReasoningEffortMappings: &invalid, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate reasoning effort mapping source") + require.Nil(t, repo.updated) +} + +func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t *testing.T) { + existing := &Group{ + ID: 1, + Name: "openai-group", + Platform: PlatformOpenAI, + Status: StatusActive, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, + } + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic}) + + require.NoError(t, err) + require.Empty(t, repo.updated.MaxReasoningEffort) + require.Empty(t, repo.updated.ReasoningEffortMappings) +} + func TestAdminService_UpdateGroup_ClearsPeakRateWhenChangingToStandard(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 0cd1c6d584..94799de52d 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -101,6 +101,11 @@ type APIKeyAuthGroupSnapshot struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` + // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` + // ReasoningEffortMappings rewrites explicit effort values before the ceiling. + ReasoningEffortMappings []ReasoningEffortMapping `json:"reasoning_effort_mappings"` + // 高峰时段倍率:PeakRateEnabled 为 true 且请求时刻处于 [PeakStart, PeakEnd) 时, // token 计费倍率额外乘以 PeakRateMultiplier(详见 Group.PeakMultiplierAt)。 // 必须随快照缓存,否则扣费路径拿到的 apiKey.Group 缺字段、高峰倍率失效。 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 4cf47c8de8..cab03fbc53 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 = 15 // v15: include group web search per-call pricing +const apiKeyAuthSnapshotVersion = 18 // v18: include group reasoning effort ceiling and mappings type apiKeyAuthCacheConfig struct { l1Size int @@ -413,6 +413,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, RPMLimit: apiKey.Group.RPMLimit, + MaxReasoningEffort: apiKey.Group.MaxReasoningEffort, + ReasoningEffortMappings: apiKey.Group.ReasoningEffortMappings, PeakRateEnabled: apiKey.Group.PeakRateEnabled, PeakStart: apiKey.Group.PeakStart, PeakEnd: apiKey.Group.PeakEnd, @@ -497,6 +499,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, RPMLimit: snapshot.Group.RPMLimit, + MaxReasoningEffort: snapshot.Group.MaxReasoningEffort, + ReasoningEffortMappings: snapshot.Group.ReasoningEffortMappings, PeakRateEnabled: snapshot.Group.PeakRateEnabled, PeakStart: snapshot.Group.PeakStart, PeakEnd: snapshot.Group.PeakEnd, diff --git a/backend/internal/service/api_key_auth_cache_version_test.go b/backend/internal/service/api_key_auth_cache_version_test.go index 5982e526c7..98a37f37d1 100644 --- a/backend/internal/service/api_key_auth_cache_version_test.go +++ b/backend/internal/service/api_key_auth_cache_version_test.go @@ -41,3 +41,21 @@ func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelsListConfig(t *testing. t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey) } } + +func TestAPIKeyService_RejectsV17AuthSnapshotWithoutReasoningEffortMappings(t *testing.T) { + svc := &APIKeyService{} + + apiKey, ok, err := svc.applyAuthCacheEntry("k-legacy-reasoning-mappings", &APIKeyAuthCacheEntry{ + Snapshot: &APIKeyAuthSnapshot{Version: 17}, + }) + + if err != nil { + t.Fatalf("expected stale snapshot to be ignored without error, got %v", err) + } + if ok { + t.Fatal("expected v17 auth snapshot to be rejected after reasoning effort mappings were added") + } + if apiKey != nil { + t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey) + } +} diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 6dfa05f8b6..18ce5576e5 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -278,6 +278,45 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig) } +func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testing.T) { + svc := NewAPIKeyService(nil, nil, nil, nil, nil, nil, &config.Config{}) + groupID := int64(9) + apiKey := &APIKey{ + ID: 1, + UserID: 2, + GroupID: &groupID, + Key: "k-reasoning-policy", + Status: StatusActive, + User: &User{ + ID: 2, + Status: StatusActive, + Role: RoleUser, + Balance: 10, + Concurrency: 3, + }, + Group: &Group{ + ID: groupID, + Name: "openai", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + }, + } + + snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) + roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot) + + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Group) + require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort) + require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings) +} + func TestAPIKeyService_GetByKey_IgnoresLegacyAuthCacheSnapshotWithoutMessagesDispatchConfig(t *testing.T) { cache := &authCacheStub{} var repoCalls int32 diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 58265b6687..e428035db5 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -12,6 +12,7 @@ import ( type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig type GroupModelsListConfig = domain.GroupModelsListConfig +type ReasoningEffortMapping = domain.ReasoningEffortMapping type Group struct { ID int64 @@ -91,6 +92,12 @@ type Group struct { // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。 RPMLimit int + // MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort. + // Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max. + MaxReasoningEffort string + // ReasoningEffortMappings rewrites explicit request values before applying the ceiling. + ReasoningEffortMappings []ReasoningEffortMapping + CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go new file mode 100644 index 0000000000..38441d5743 --- /dev/null +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -0,0 +1,198 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + maxReasoningEffortMappings = 64 + maxReasoningEffortValueLen = 64 +) + +var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} + +// NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. +// Empty means that the group does not impose a ceiling. +func NormalizeMaxReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + switch value { + case "": + return "" + case "none": + return "none" + case "minimal": + return "minimal" + case "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + case "xhigh", "extrahigh": + return "xhigh" + case "max": + return "max" + default: + return "" + } +} + +func reasoningEffortValuesForPlatform(platform string) []string { + if platform != PlatformOpenAI { + return nil + } + return openAIReasoningEffortValues +} + +func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + + allowedValues := reasoningEffortValuesForPlatform(platform) + if len(allowedValues) == 0 { + return "", fmt.Errorf("reasoning effort policy is only supported for platform %q", PlatformOpenAI) + } + + value := NormalizeMaxReasoningEffort(raw) + for _, allowed := range allowedValues { + if value == allowed { + return value, nil + } + } + return "", fmt.Errorf( + "reasoning effort %q is not supported for platform %q; allowed values: %s", + raw, + platform, + strings.Join(allowedValues, ", "), + ) +} + +func reasoningEffortRank(raw string) (int, bool) { + switch NormalizeMaxReasoningEffort(raw) { + case "none": + return 0, true + case "minimal": + return 1, true + case "low": + return 2, true + case "medium": + return 3, true + case "high": + return 4, true + case "xhigh": + return 5, true + case "max": + return 6, true + default: + return 0, false + } +} + +// NormalizeReasoningEffortMappings validates group mapping rules against the +// fixed effort values supported by OpenAI groups. +func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { + if len(raw) > maxReasoningEffortMappings { + return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings) + } + + normalized := make([]ReasoningEffortMapping, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for i, mapping := range raw { + from := NormalizeMaxReasoningEffort(mapping.From) + to := NormalizeMaxReasoningEffort(mapping.To) + if from == "" || to == "" { + return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) + } + if len(from) > maxReasoningEffortValueLen || len(to) > maxReasoningEffortValueLen { + return nil, fmt.Errorf("reasoning effort mapping %d values cannot exceed %d characters", i+1, maxReasoningEffortValueLen) + } + if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err) + } + if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + } + key := from + if _, exists := seen[key]; exists { + return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from) + } + seen[key] = struct{}{} + normalized = append(normalized, ReasoningEffortMapping{From: from, To: to}) + } + return normalized, nil +} + +func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) { + value := strings.TrimSpace(raw) + canonical := NormalizeMaxReasoningEffort(value) + for _, mapping := range mappings { + if canonical != "" && canonical == NormalizeMaxReasoningEffort(mapping.From) { + return strings.TrimSpace(mapping.To), true + } + } + return value, false +} + +func sanitizeGroupReasoningEffortPolicy(group *Group) { + if group == nil { + return + } + maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort) + mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings) + if maxErr != nil { + maxEffort = "" + } + if mappingsErr != nil { + mappings = []ReasoningEffortMapping{} + } + group.MaxReasoningEffort = maxEffort + group.ReasoningEffortMappings = mappings +} + +// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then caps +// known effort levels. Omitted values remain untouched so upstream defaults +// stay in control. +func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping) ([]byte, bool) { + maxRank, hasMax := reasoningEffortRank(maxEffort) + if len(body) == 0 || (!hasMax && len(mappings) == 0) { + return body, false + } + + result := body + changed := false + for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + field := gjson.GetBytes(result, path) + if !field.Exists() || field.Type != gjson.String { + continue + } + original := strings.TrimSpace(field.String()) + if original == "" { + continue + } + + effective, _ := mapReasoningEffort(original, mappings) + if currentRank, recognized := reasoningEffortRank(effective); recognized { + effective = NormalizeMaxReasoningEffort(effective) + if hasMax && currentRank > maxRank { + effective = NormalizeMaxReasoningEffort(maxEffort) + } + } + if effective == original { + continue + } + + updated, err := sjson.SetBytes(result, path, effective) + if err != nil { + continue + } + result = updated + changed = true + } + return result, changed +} diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go new file mode 100644 index 0000000000..41df07a3c3 --- /dev/null +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestNormalizeMaxReasoningEffort(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: ""}, + {name: "separator", in: "x-high", want: "xhigh"}, + {name: "max is distinct", in: "max", want: "max"}, + {name: "invalid", in: "banana", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, NormalizeMaxReasoningEffort(tt.in)) + }) + } +} + +func TestNormalizeReasoningEffortMappings(t *testing.T) { + t.Run("canonicalizes fixed OpenAI values", func(t *testing.T) { + got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ + {From: " MAX ", To: " x-high "}, + {From: "minimal", To: "high"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + {From: "minimal", To: "high"}, + }, got) + }) + + t.Run("rejects empty values", func(t *testing.T) { + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "max"}}) + require.ErrorContains(t, err, "empty or unknown") + }) + + t.Run("rejects duplicate sources case insensitively", func(t *testing.T) { + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + {From: " MAX ", To: "high"}, + }) + require.ErrorContains(t, err, "duplicate") + }) + + t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) + require.ErrorContains(t, err, "only supported for platform \"openai\"") + } + + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) + require.ErrorContains(t, err, "not supported for platform") + + _, err = NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) + require.ErrorContains(t, err, "empty or unknown") + }) +} + +func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { + value, err := normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "max") + require.NoError(t, err) + require.Equal(t, "max", value) + + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") + require.ErrorContains(t, err, "only supported for platform \"openai\"") + } + + _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") + require.ErrorContains(t, err, "not supported") +} + +func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { + tests := []struct { + name string + body string + max string + mappings []ReasoningEffortMapping + path string + want string + changed bool + }{ + {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, + {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, + {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false}, + {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false}, + {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, + {name: "caps max below its distinct rank", body: `{"reasoning_effort":"max"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, + {name: "keeps xhigh below max", body: `{"reasoning_effort":"xhigh"}`, max: "max", path: "reasoning_effort", want: "xhigh", changed: false}, + {name: "caps both shapes", body: `{"reasoning":{"effort":"high"},"reasoning_effort":"xhigh"}`, max: "low", path: "reasoning.effort", want: "low", changed: true}, + {name: "maps before cap", body: `{"reasoning":{"effort":"MAX"}}`, max: "medium", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "medium", changed: true}, + {name: "does not chain mappings", body: `{"reasoning_effort":"max"}`, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}, {From: "xhigh", To: "low"}}, path: "reasoning_effort", want: "xhigh", changed: true}, + {name: "keeps unknown without mapping", body: `{"reasoning_effort":"future"}`, max: "low", path: "reasoning_effort", want: "future", changed: false}, + {name: "keeps non string value", body: `{"reasoning_effort":{"level":"high"}}`, max: "low", path: "reasoning_effort.level", want: "high", changed: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, changed := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings) + require.Equal(t, tt.changed, changed) + if tt.path != "" { + require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String()) + } + }) + } +} diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index e0bd769c3c..d22bec21d5 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -210,9 +210,13 @@ type OpenAIWSIngressHooks struct { // InitialRequestModel 是首帧渠道映射前的请求模型,只用于 usage metadata // 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。 InitialRequestModel string - BeforeTurn func(turn int) error - BeforeRequest func(turn int, payload []byte, originalModel string) error - AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) + // MaxReasoningEffort limits explicit reasoning effort values for this WS session. + MaxReasoningEffort string + // ReasoningEffortMappings rewrites explicit effort values for this WS session. + ReasoningEffortMappings []ReasoningEffortMapping + BeforeTurn func(turn int) error + BeforeRequest func(turn int, payload []byte, originalModel string) error + AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) } func (s *OpenAIGatewayService) getOpenAIWSConnPool() *openAIWSConnPool { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 67bffe6ce2..c896ec7947 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -197,6 +197,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( nil, ) } + if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { + if capped, changed := ApplyOpenAIReasoningEffortPolicy(normalized, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { + normalized = capped + } + } originalModel := strings.TrimSpace(values[1].String()) modelMissing := originalModel == "" diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index f61cb572b3..934a35fcc8 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -627,6 +627,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } firstClientMessage = liteFirstMessage } + if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { + if capped, changed := ApplyOpenAIReasoningEffortPolicy(firstClientMessage, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { + firstClientMessage = capped + } + } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) logOpenAIWSV2Passthrough( @@ -867,6 +872,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } payload = litePayload } + if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { + if capped, changed := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { + payload = capped + } + } } if isResponseCreate && hooks != nil && hooks.BeforeRequest != nil { turnNo := int(completedTurns.Load()) + 1 diff --git a/backend/migrations/185_group_max_reasoning_effort.sql b/backend/migrations/185_group_max_reasoning_effort.sql new file mode 100644 index 0000000000..5c7d35a41f --- /dev/null +++ b/backend/migrations/185_group_max_reasoning_effort.sql @@ -0,0 +1,3 @@ +-- Add a per-group ceiling for OpenAI/Codex reasoning effort. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS max_reasoning_effort VARCHAR(20) NOT NULL DEFAULT ''; diff --git a/backend/migrations/186_group_reasoning_effort_mappings.sql b/backend/migrations/186_group_reasoning_effort_mappings.sql new file mode 100644 index 0000000000..d843785895 --- /dev/null +++ b/backend/migrations/186_group_reasoning_effort_mappings.sql @@ -0,0 +1,3 @@ +-- Add exact rewrites for explicit OpenAI/Codex reasoning effort values. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS reasoning_effort_mappings JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue new file mode 100644 index 0000000000..ba363d7451 --- /dev/null +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 82ac969a7e..13548a13a0 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -7,7 +7,9 @@ :disabled="disabled" :aria-expanded="isOpen" :aria-haspopup="true" - aria-label="Select option" + :id="id" + :aria-label="ariaLabel ?? 'Select option'" + :aria-describedby="ariaDescribedby" :class="[ 'select-trigger', isOpen && 'select-trigger-open', @@ -65,6 +67,7 @@ v-model="searchQuery" type="text" :placeholder="searchPlaceholderText" + :aria-label="searchPlaceholderText" class="select-search-input" @click.stop /> @@ -148,6 +151,9 @@ interface Props { creatable?: boolean creatablePrefix?: string clearable?: boolean + id?: string + ariaLabel?: string + ariaDescribedby?: string } interface Emits { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index e35bfe09ce..360dd47127 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -848,6 +848,21 @@ export default { rpmLimit: 'Requests Per Minute (RPM)', rpmLimitPlaceholder: '0 = unlimited', rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).', + maxReasoningEffort: 'Max reasoning effort', + maxReasoningEffortUnlimited: 'Unlimited (follow request)', + maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', + reasoningEffortMappings: 'Reasoning effort mappings', + addReasoningEffortMapping: 'Add mapping', + removeReasoningEffortMapping: 'Remove mapping', + reasoningEffortFrom: 'Request value', + reasoningEffortTo: 'Forwarded value', + reasoningEffortFromPlaceholder: 'Select A', + reasoningEffortToPlaceholder: 'Select B', + fromRequired: 'Select request value A', + toRequired: 'Select forwarded value B', + unsupportedFrom: 'Request value is not supported by this platform', + unsupportedTo: 'Forwarded value is not supported by this platform', + duplicateFrom: 'Request value A must be unique', exclusiveLabel: 'Exclusive Group', exclusiveHint: 'Exclusive group, can be manually assigned to users', platformLabel: 'Platform Restriction', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 2fde589193..82311c555f 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -830,6 +830,21 @@ export default { rpmLimit: '每分钟请求数 (RPM)', rpmLimitPlaceholder: '0 表示不限制', rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', + maxReasoningEffort: '推理强度上限', + maxReasoningEffortUnlimited: '不限制(跟随请求)', + maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', + reasoningEffortMappings: '推理强度映射', + addReasoningEffortMapping: '添加映射', + removeReasoningEffortMapping: '删除映射', + reasoningEffortFrom: '请求值', + reasoningEffortTo: '转发值', + reasoningEffortFromPlaceholder: '请选择 A', + reasoningEffortToPlaceholder: '请选择 B', + fromRequired: '请选择请求值 A', + toRequired: '请选择转发值 B', + unsupportedFrom: '请求值不受当前平台支持', + unsupportedTo: '转发值不受当前平台支持', + duplicateFrom: '请求值 A 不能重复', exclusiveLabel: '专属分组', exclusiveHint: '专属分组,可以手动指定给用户', platformLabel: '平台限制', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f49d9c419d..3ed6a87b9a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -503,6 +503,11 @@ export interface OpenAIMessagesDispatchModelConfig { exact_model_mappings?: Record } +export interface ReasoningEffortMapping { + from: string + to: string +} + export interface Group { id: number name: string @@ -510,6 +515,8 @@ export interface Group { platform: GroupPlatform rate_multiplier: number rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set + max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited + reasoning_effort_mappings?: ReasoningEffortMapping[] is_exclusive: boolean status: 'active' | 'inactive' subscription_type: SubscriptionType @@ -683,6 +690,8 @@ export interface CreateGroupRequest { model_routing?: Record | null model_routing_enabled?: boolean rpm_limit?: number + max_reasoning_effort?: string + reasoning_effort_mappings?: ReasoningEffortMapping[] require_oauth_only?: boolean require_privacy_set?: boolean // 从指定分组复制账号 @@ -731,6 +740,8 @@ export interface UpdateGroupRequest { model_routing?: Record | null model_routing_enabled?: boolean rpm_limit?: number + max_reasoning_effort?: string + reasoning_effort_mappings?: ReasoningEffortMapping[] require_oauth_only?: boolean require_privacy_set?: boolean copy_accounts_from_group_ids?: number[] diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 635488479b..6f796c27e5 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -599,6 +599,14 @@ />

{{ t("admin.groups.form.rpmLimitHint") }}

+

{{ t("admin.groups.form.rpmLimitHint") }}

+