Merge pull request #4674 from AdrianZhaoDev/main

feat(groups): add OpenAI reasoning policy
This commit is contained in:
Wesley Liddick
2026-07-22 09:37:06 +08:00
committed by GitHub
43 changed files with 1637 additions and 11 deletions
+26 -2
View File
@@ -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()
}
+17
View File
@@ -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) {
+70
View File
@@ -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) {
+127
View File
@@ -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 {
+84
View File
@@ -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,
+2
View File
@@ -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{
+125 -1
View File
@@ -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)
}
+10
View File
@@ -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
+10
View File
@@ -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 自定义精确映射;先映射再应用上限"),
}
}
@@ -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"`
}
@@ -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 {
@@ -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)
})
}
+2
View File
@@ -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,
}
+4
View File
@@ -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"`
@@ -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)
@@ -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
@@ -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,
@@ -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).
@@ -381,6 +381,8 @@ func TestAPIContracts(t *testing.T) {
"fallback_group_id_on_invalid_request": null,
"require_oauth_only": false,
"require_privacy_set": false,
"max_reasoning_effort": "",
"reasoning_effort_mappings": null,
"rpm_limit": 0,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
+27
View File
@@ -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
@@ -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
@@ -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)
}
@@ -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
}
@@ -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,
@@ -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 缺字段、高峰倍率失效。
@@ -14,7 +14,7 @@ import (
"github.com/dgraph-io/ristretto"
)
const apiKeyAuthSnapshotVersion = 15 // v15: include group web search per-call pricing
const apiKeyAuthSnapshotVersion = 16 // v16: 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,
@@ -41,3 +41,21 @@ func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelsListConfig(t *testing.
t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey)
}
}
func TestAPIKeyService_RejectsV15AuthSnapshotWithoutReasoningEffortPolicy(t *testing.T) {
svc := &APIKeyService{}
apiKey, ok, err := svc.applyAuthCacheEntry("k-legacy-reasoning-mappings", &APIKeyAuthCacheEntry{
Snapshot: &APIKeyAuthSnapshot{Version: 15},
})
if err != nil {
t.Fatalf("expected stale snapshot to be ignored without error, got %v", err)
}
if ok {
t.Fatal("expected v15 auth snapshot to be rejected after reasoning effort policy was added")
}
if apiKey != nil {
t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey)
}
}
@@ -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
+7
View File
@@ -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
@@ -0,0 +1,194 @@
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 "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 "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
}
@@ -0,0 +1,116 @@
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: "none is unsupported", in: "none", want: ""},
{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, "empty or unknown")
_, 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: "ignores stale none ceiling", body: `{"reasoning_effort":"high"}`, max: "none", path: "reasoning_effort", want: "high", 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())
}
})
}
}
@@ -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 {
@@ -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 == ""
@@ -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
@@ -0,0 +1,4 @@
-- Add per-group controls for explicit OpenAI/Codex reasoning effort values.
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS max_reasoning_effort VARCHAR(20) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reasoning_effort_mappings JSONB NOT NULL DEFAULT '[]'::jsonb;
@@ -0,0 +1,197 @@
<template>
<div class="space-y-4">
<div>
<label :for="`${idPrefix}-max-effort`" class="input-label">
{{ t("admin.groups.form.maxReasoningEffort") }}
</label>
<Select
:id="`${idPrefix}-max-effort`"
:model-value="maxEffort"
:options="reasoningEffortOptions"
:placeholder="t('admin.groups.form.maxReasoningEffortUnlimited')"
:aria-label="t('admin.groups.form.maxReasoningEffort')"
:searchable="false"
clearable
@update:model-value="updateMaxEffort"
/>
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortHint") }}</p>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between gap-3">
<label class="input-label mb-0">
{{ t("admin.groups.form.reasoningEffortMappings") }}
</label>
<button
type="button"
class="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
@click="addMapping"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.form.addReasoningEffortMapping") }}
</button>
</div>
<div v-if="mappings.length > 0" class="space-y-2">
<div
v-for="row in mappings"
:key="row.id"
class="rounded-lg border border-gray-200 bg-gray-50/40 p-3 dark:border-dark-600 dark:bg-dark-800/40"
>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] md:items-start">
<div>
<label :for="`${idPrefix}-${row.id}-from`" class="input-label">
{{ t("admin.groups.form.reasoningEffortFrom") }}
</label>
<Select
:id="`${idPrefix}-${row.id}-from`"
:model-value="row.from"
:options="reasoningEffortOptions"
:placeholder="t('admin.groups.form.reasoningEffortFromPlaceholder')"
:error="showValidation && !!validationErrors[row.id]?.from"
:aria-label="t('admin.groups.form.reasoningEffortFrom')"
:aria-describedby="showValidation && validationErrors[row.id]?.from ? `${idPrefix}-${row.id}-from-error` : undefined"
:searchable="false"
clearable
@update:model-value="updateMapping(row.id, 'from', $event)"
/>
<p
v-if="showValidation && validationErrors[row.id]?.from"
:id="`${idPrefix}-${row.id}-from-error`"
class="mt-1 text-xs text-red-600 dark:text-red-400"
role="alert"
>
{{ mappingErrorText(validationErrors[row.id]?.from) }}
</p>
</div>
<div class="hidden pt-8 text-gray-400 md:block dark:text-dark-400">
<Icon name="arrowRight" size="sm" />
</div>
<div>
<label :for="`${idPrefix}-${row.id}-to`" class="input-label">
{{ t("admin.groups.form.reasoningEffortTo") }}
</label>
<Select
:id="`${idPrefix}-${row.id}-to`"
:model-value="row.to"
:options="reasoningEffortOptions"
:placeholder="t('admin.groups.form.reasoningEffortToPlaceholder')"
:error="showValidation && !!validationErrors[row.id]?.to"
:aria-label="t('admin.groups.form.reasoningEffortTo')"
:aria-describedby="showValidation && validationErrors[row.id]?.to ? `${idPrefix}-${row.id}-to-error` : undefined"
:searchable="false"
clearable
@update:model-value="updateMapping(row.id, 'to', $event)"
/>
<p
v-if="showValidation && validationErrors[row.id]?.to"
:id="`${idPrefix}-${row.id}-to-error`"
class="mt-1 text-xs text-red-600 dark:text-red-400"
role="alert"
>
{{ mappingErrorText(validationErrors[row.id]?.to) }}
</p>
</div>
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 md:mt-6 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('admin.groups.form.removeReasoningEffortMapping')"
:aria-label="t('admin.groups.form.removeReasoningEffortMapping')"
@click="removeMapping(row.id)"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { GroupPlatform } from "@/types";
import Icon from "@/components/icons/Icon.vue";
import Select from "@/components/common/Select.vue";
import {
createReasoningEffortMappingRow,
reasoningEffortOptionsForPlatform,
validateReasoningEffortMappings,
type ReasoningEffortMappingErrorCode,
type ReasoningEffortMappingRow,
} from "@/views/admin/groupsReasoningEffort";
const props = defineProps<{
idPrefix: string;
platform: GroupPlatform;
maxEffort: string;
mappings: ReasoningEffortMappingRow[];
}>();
const emit = defineEmits<{
(event: "update:maxEffort", value: string): void;
(event: "update:mappings", value: ReasoningEffortMappingRow[]): void;
}>();
const { t } = useI18n();
const showValidation = ref(false);
const reasoningEffortOptions = computed(() =>
reasoningEffortOptionsForPlatform(props.platform),
);
const validationErrors = computed(() =>
validateReasoningEffortMappings(props.mappings, props.platform),
);
const asString = (value: string | number | boolean | null): string =>
value == null ? "" : String(value);
const updateMaxEffort = (value: string | number | boolean | null) => {
emit("update:maxEffort", asString(value));
};
const updateMapping = (
id: string,
field: "from" | "to",
value: string | number | boolean | null,
) => {
emit(
"update:mappings",
props.mappings.map((row) =>
row.id === id ? { ...row, [field]: asString(value) } : row,
),
);
};
const addMapping = () => {
emit("update:mappings", [
...props.mappings,
createReasoningEffortMappingRow(),
]);
};
const removeMapping = (id: string) => {
emit(
"update:mappings",
props.mappings.filter((row) => row.id !== id),
);
};
const mappingErrorText = (
code: ReasoningEffortMappingErrorCode | undefined,
): string => (code ? t(`admin.groups.form.${code}`) : "");
const validate = (): boolean => {
showValidation.value = true;
return Object.keys(validationErrors.value).length === 0;
};
const resetValidation = () => {
showValidation.value = false;
};
defineExpose({ validate, resetValidation });
</script>
+7 -1
View File
@@ -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 {
@@ -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',
@@ -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: '平台限制',
+11
View File
@@ -503,6 +503,11 @@ export interface OpenAIMessagesDispatchModelConfig {
exact_model_mappings?: Record<string, string>
}
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<string, number[]> | 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<string, number[]> | 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[]
+85
View File
@@ -599,6 +599,14 @@
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="createForm.platform === 'openai'"
ref="createReasoningEffortPolicyRef"
id-prefix="create-group-reasoning"
:platform="createForm.platform"
v-model:max-effort="createForm.max_reasoning_effort"
v-model:mappings="createForm.reasoning_effort_mappings"
/>
<div
v-if="createForm.subscription_type !== 'subscription'"
data-tour="group-form-exclusive"
@@ -2111,6 +2119,14 @@
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="editForm.platform === 'openai'"
ref="editReasoningEffortPolicyRef"
id-prefix="edit-group-reasoning"
:platform="editForm.platform"
v-model:max-effort="editForm.max_reasoning_effort"
v-model:mappings="editForm.reasoning_effort_mappings"
/>
<div v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -3601,6 +3617,7 @@ import Icon from "@/components/icons/Icon.vue";
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
@@ -3623,6 +3640,12 @@ import {
} from "./groupsModelsList";
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
import {
normalizeReasoningEffortForPlatform,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
type ReasoningEffortMappingRow,
} from "./groupsReasoningEffort";
import {
getDefaultImagePreviewPrice,
getDefaultVideoPreviewPrice,
@@ -4000,6 +4023,12 @@ const createModelsListState = reactive(createInitialModelsListState());
const editModelsListState = reactive(createInitialModelsListState());
const createModelsListLoading = ref(false);
const editModelsListLoading = ref(false);
type ReasoningEffortPolicyFieldsExpose = {
validate: () => boolean;
resetValidation: () => void;
};
const createReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
const editReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
const modelsListCandidatesTracker = createModelsListCandidatesTracker();
const createModelsListSelectedCount = computed(
() => createModelsListState.items.filter((item) => item.selected).length,
@@ -4064,6 +4093,8 @@ const createForm = reactive({
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
// 简单账号类型(用于模型路由选择)
@@ -4412,6 +4443,8 @@ const editForm = reactive({
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
type ImagePricingFormState = {
@@ -4801,6 +4834,9 @@ const closeCreateModal = () => {
createForm.mcp_xml_inject = true;
createForm.copy_accounts_from_group_ids = [];
createForm.rpm_limit = 0;
createForm.max_reasoning_effort = "";
createForm.reasoning_effort_mappings = [];
createReasoningEffortPolicyRef.value?.resetValidation();
resetModelsListState(createModelsListState);
createModelRoutingRules.value = [];
};
@@ -4839,6 +4875,13 @@ const handleCreateGroup = async () => {
appStore.showError(t("admin.groups.nameRequired"));
return;
}
if (
createForm.platform === "openai" &&
createReasoningEffortPolicyRef.value &&
!createReasoningEffortPolicyRef.value.validate()
) {
return;
}
submitting.value = true;
try {
// 构建请求数据,包含模型路由配置
@@ -4871,6 +4914,9 @@ const handleCreateGroup = async () => {
exact_model_mappings: createForm.exact_model_mappings,
})
: undefined,
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
createForm.reasoning_effort_mappings,
),
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => (v === "" ? null : v);
@@ -4985,6 +5031,14 @@ const handleEdit = async (group: AdminGroup) => {
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
editForm.rpm_limit = group.rpm_limit ?? 0;
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
group.platform,
group.max_reasoning_effort,
);
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
group.reasoning_effort_mappings,
group.platform,
);
resetModelsListState(editModelsListState, group.models_list_config);
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
@@ -5001,6 +5055,9 @@ const closeEditModal = () => {
clearAllAccountSearchState();
showEditModal.value = false;
editingGroup.value = null;
editForm.max_reasoning_effort = "";
editForm.reasoning_effort_mappings = [];
editReasoningEffortPolicyRef.value?.resetValidation();
editModelRoutingRules.value = [];
editForm.copy_accounts_from_group_ids = [];
editForm.peak_rate_enabled = false;
@@ -5023,6 +5080,13 @@ const handleUpdateGroup = async () => {
appStore.showError(t("admin.groups.nameRequired"));
return;
}
if (
editForm.platform === "openai" &&
editReasoningEffortPolicyRef.value &&
!editReasoningEffortPolicyRef.value.validate()
) {
return;
}
submitting.value = true;
try {
@@ -5062,6 +5126,9 @@ const handleUpdateGroup = async () => {
exact_model_mappings: editForm.exact_model_mappings,
})
: undefined,
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
editForm.reasoning_effort_mappings,
),
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => (v === "" ? null : v);
@@ -5227,6 +5294,15 @@ watch(
if (newVal !== "openai") {
resetMessagesDispatchFormState(createForm);
}
createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
newVal,
createForm.max_reasoning_effort,
);
createForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(createForm.reasoning_effort_mappings),
newVal,
);
createReasoningEffortPolicyRef.value?.resetValidation();
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
createForm.require_oauth_only = false;
createForm.require_privacy_set = false;
@@ -5260,6 +5336,15 @@ watch(
if (newVal !== "openai") {
resetMessagesDispatchFormState(editForm);
}
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
newVal,
editForm.max_reasoning_effort,
);
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(editForm.reasoning_effort_mappings),
newVal,
);
editReasoningEffortPolicyRef.value?.resetValidation();
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
editForm.require_oauth_only = false;
editForm.require_privacy_set = false;
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
createReasoningEffortMappingRow,
normalizeReasoningEffortForPlatform,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
reasoningEffortOptionsForPlatform,
validateReasoningEffortMappings,
} from "../groupsReasoningEffort";
describe("groupsReasoningEffort", () => {
it("provides fixed OpenAI choices without none", () => {
expect(
reasoningEffortOptionsForPlatform("openai").map((option) => option.value),
).toEqual([
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
for (const platform of [
"anthropic",
"gemini",
"antigravity",
"grok",
] as const) {
expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]);
}
});
it("hydrates supported rows and drops stale custom values", () => {
const rows = reasoningEffortMappingsToRows(
[
{ from: " max ", to: " xhigh " },
{ from: "ultra", to: "high" },
],
"openai",
);
expect(rows).toHaveLength(1);
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
{ from: "max", to: "xhigh" },
]);
});
it("clears values unsupported by OpenAI or used on another platform", () => {
expect(normalizeReasoningEffortForPlatform("openai", " MAX ")).toBe("max");
expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe("");
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
});
it("requires both sides of every mapping", () => {
const first = createReasoningEffortMappingRow({ to: "low" });
const second = createReasoningEffortMappingRow({ from: "max" });
expect(validateReasoningEffortMappings([first, second])).toEqual({
[first.id]: { from: "fromRequired" },
[second.id]: { to: "toRequired" },
});
});
it("rejects duplicate source values case insensitively", () => {
const first = createReasoningEffortMappingRow({ from: "MAX", to: "xhigh" });
const second = createReasoningEffortMappingRow({ from: " max ", to: "high" });
expect(validateReasoningEffortMappings([first, second])).toEqual({
[first.id]: { from: "duplicateFrom" },
[second.id]: { from: "duplicateFrom" },
});
});
it("rejects custom mappings", () => {
const row = createReasoningEffortMappingRow({ from: "ultra", to: "high" });
expect(validateReasoningEffortMappings([row], "openai")).toEqual({
[row.id]: { from: "unsupportedFrom" },
});
});
});
@@ -0,0 +1,120 @@
import type { GroupPlatform, ReasoningEffortMapping } from "@/types";
const openAIReasoningEffortValues = [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
const reasoningEffortValuesForPlatform = (
platform: GroupPlatform,
): readonly string[] =>
platform === "openai" ? openAIReasoningEffortValues : [];
export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) {
return reasoningEffortValuesForPlatform(platform).map((value) => ({
value,
label: value,
}));
}
export function normalizeReasoningEffortForPlatform(
platform: GroupPlatform,
value: string | null | undefined,
): string {
const normalized = value?.trim().toLowerCase() ?? "";
return reasoningEffortValuesForPlatform(platform).some(
(allowed) => allowed === normalized,
)
? normalized
: "";
}
export interface ReasoningEffortMappingRow extends ReasoningEffortMapping {
id: string;
}
export type ReasoningEffortMappingErrorCode =
| "fromRequired"
| "toRequired"
| "duplicateFrom"
| "unsupportedFrom"
| "unsupportedTo";
export type ReasoningEffortMappingErrors = Record<
string,
Partial<Record<"from" | "to", ReasoningEffortMappingErrorCode>>
>;
let nextMappingRowID = 0;
export function createReasoningEffortMappingRow(
mapping: Partial<ReasoningEffortMapping> = {},
): ReasoningEffortMappingRow {
nextMappingRowID += 1;
return {
id: `reasoning-effort-mapping-${nextMappingRowID}`,
from: mapping.from ?? "",
to: mapping.to ?? "",
};
}
export function reasoningEffortMappingsToRows(
mappings?: ReasoningEffortMapping[] | null,
platform: GroupPlatform = "openai",
): ReasoningEffortMappingRow[] {
return (mappings ?? []).flatMap((mapping) => {
const from = normalizeReasoningEffortForPlatform(platform, mapping.from);
const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
return from && to
? [createReasoningEffortMappingRow({ from, to })]
: [];
});
}
export function reasoningEffortMappingsToAPI(
rows: ReasoningEffortMappingRow[],
): ReasoningEffortMapping[] {
return rows.map((row) => ({
from: row.from.trim(),
to: row.to.trim(),
}));
}
export function validateReasoningEffortMappings(
rows: ReasoningEffortMappingRow[],
platform: GroupPlatform = "openai",
): ReasoningEffortMappingErrors {
const errors: ReasoningEffortMappingErrors = {};
const sourceRows = new Map<string, ReasoningEffortMappingRow[]>();
rows.forEach((row) => {
const from = row.from.trim();
const to = row.to.trim();
if (!from) {
errors[row.id] = { ...errors[row.id], from: "fromRequired" };
} else if (!normalizeReasoningEffortForPlatform(platform, from)) {
errors[row.id] = { ...errors[row.id], from: "unsupportedFrom" };
} else {
const key = from.toLowerCase();
sourceRows.set(key, [...(sourceRows.get(key) ?? []), row]);
}
if (!to) {
errors[row.id] = { ...errors[row.id], to: "toRequired" };
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
errors[row.id] = { ...errors[row.id], to: "unsupportedTo" };
}
});
sourceRows.forEach((duplicateRows) => {
if (duplicateRows.length < 2) return;
duplicateRows.forEach((row) => {
errors[row.id] = { ...errors[row.id], from: "duplicateFrom" };
});
});
return errors;
}