mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #4674 from AdrianZhaoDev/main
feat(groups): add OpenAI reasoning policy
This commit is contained in:
+26
-2
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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: '平台限制',
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user