mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
feat(groups): add deny-or-downgrade control for reasoning effort ceiling
OpenAI/Composite groups can now reject requests that exceed the max reasoning effort instead of always rewriting them down to the ceiling. Existing groups keep the previous auto-downgrade behavior.
This commit is contained in:
+12
-1
@@ -136,6 +136,8 @@ type Group struct {
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
@@ -258,7 +260,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, group.FieldMaxReasoningEffort:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -662,6 +664,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffort = value.String
|
||||
}
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffortOverLimit = 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])
|
||||
@@ -976,6 +984,9 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("max_reasoning_effort=")
|
||||
builder.WriteString(_m.MaxReasoningEffort)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort_over_limit=")
|
||||
builder.WriteString(_m.MaxReasoningEffortOverLimit)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -132,6 +132,8 @@ const (
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
|
||||
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
|
||||
@@ -273,6 +275,7 @@ var Columns = []string{
|
||||
FieldModelsListConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
FieldReasoningEffortMappings,
|
||||
FieldProfitControlEnabled,
|
||||
FieldProfitMinMargin,
|
||||
@@ -404,6 +407,10 @@ var (
|
||||
DefaultMaxReasoningEffort string
|
||||
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
|
||||
DefaultMaxReasoningEffortOverLimit string
|
||||
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
MaxReasoningEffortOverLimitValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
|
||||
@@ -682,6 +689,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
|
||||
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
|
||||
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -315,6 +315,11 @@ func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
|
||||
func MaxReasoningEffortOverLimit(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
|
||||
func ProfitControlEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
@@ -2460,6 +2465,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
|
||||
@@ -802,6 +802,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1123,6 +1137,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMaxReasoningEffort
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
v := group.DefaultMaxReasoningEffortOverLimit
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1317,6 +1335,14 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %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"`)}
|
||||
}
|
||||
@@ -1588,6 +1614,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
_node.MaxReasoningEffort = value
|
||||
}
|
||||
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
_node.MaxReasoningEffortOverLimit = value
|
||||
}
|
||||
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
@@ -2708,6 +2738,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
|
||||
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
|
||||
u.Set(group.FieldReasoningEffortMappings, v)
|
||||
@@ -3922,6 +3964,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5312,6 +5368,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -1094,6 +1094,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1487,6 +1501,11 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1821,6 +1840,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
@@ -3215,6 +3237,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -3621,6 +3657,11 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3972,6 +4013,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
|
||||
@@ -961,6 +961,7 @@ var (
|
||||
{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: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
|
||||
+55
-1
@@ -22166,6 +22166,7 @@ type GroupMutation struct {
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
max_reasoning_effort_over_limit *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
profit_control_enabled *bool
|
||||
@@ -25216,6 +25217,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
|
||||
m.max_reasoning_effort = nil
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
|
||||
m.max_reasoning_effort_over_limit = &s
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
|
||||
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
|
||||
v := m.max_reasoning_effort_over_limit
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" 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) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
|
||||
}
|
||||
return oldValue.MaxReasoningEffortOverLimit, nil
|
||||
}
|
||||
|
||||
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
|
||||
m.max_reasoning_effort_over_limit = nil
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.reasoning_effort_mappings = &dem
|
||||
@@ -25773,7 +25810,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 62)
|
||||
fields := make([]string, 0, 63)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25948,6 +25985,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.max_reasoning_effort != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffort)
|
||||
}
|
||||
if m.max_reasoning_effort_over_limit != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
|
||||
}
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
@@ -26084,6 +26124,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.MaxReasoningEffortOverLimit()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26217,6 +26259,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.OldMaxReasoningEffortOverLimit(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26640,6 +26684,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMaxReasoningEffort(v)
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMaxReasoningEffortOverLimit(v)
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
v, ok := value.([]domain.ReasoningEffortMapping)
|
||||
if !ok {
|
||||
@@ -27353,6 +27404,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMaxReasoningEffort:
|
||||
m.ResetMaxReasoningEffort()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
m.ResetMaxReasoningEffortOverLimit()
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
|
||||
@@ -1189,20 +1189,26 @@ func init() {
|
||||
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)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[55].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[56].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[56].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[57].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[57].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[58].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[59].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
|
||||
@@ -271,6 +271,10 @@ func (Group) Fields() []ent.Field {
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.String("max_reasoning_effort_over_limit").
|
||||
MaxLen(20).
|
||||
Default("downgrade").
|
||||
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
|
||||
@@ -156,6 +156,8 @@ type CreateGroupRequest struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
@@ -225,6 +227,8 @@ type UpdateGroupRequest struct {
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
@@ -557,6 +561,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -686,6 +691,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -59,17 +60,17 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
@@ -100,13 +101,21 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request
|
||||
result.RequestedReasoningEffort = requested
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
|
||||
if c == nil || err == nil || write == nil {
|
||||
return
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
@@ -121,9 +130,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
|
||||
@@ -125,7 +126,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
require.Equal(t, "max", *requested)
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
@@ -133,14 +135,27 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
denyGroup := *group
|
||||
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
denyAPIKey := &service.APIKey{Group: &denyGroup}
|
||||
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
|
||||
@@ -153,6 +153,8 @@ type Group struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
|
||||
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
|
||||
@@ -435,7 +435,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
|
||||
@@ -2530,7 +2533,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
var turnStartsMu sync.Mutex
|
||||
turnStarts := make(map[int]time.Time, 4)
|
||||
@@ -2557,12 +2560,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
|
||||
c.Set(securityAuditWSTurnContextKey, turn)
|
||||
service.BeginOpsStreamTurn(c, turn)
|
||||
|
||||
@@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
strings.Contains(msg, "this group is restricted to claude code clients") ||
|
||||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
|
||||
strings.Contains(msg, "image generation is not enabled for this group") ||
|
||||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
|
||||
strings.Contains(msg, "token counting is not supported for this platform") ||
|
||||
strings.Contains(msg, "images api is not supported for this platform") ||
|
||||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
|
||||
|
||||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group reasoning effort over limit deny",
|
||||
errType: "permission_error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route token counting platform unsupported",
|
||||
errType: "not_found_error",
|
||||
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider reasoning effort over limit shaped error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider token counting unsupported shaped error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
|
||||
@@ -215,6 +215,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
group.FieldModelsListConfig,
|
||||
group.FieldRpmLimit,
|
||||
group.FieldMaxReasoningEffort,
|
||||
group.FieldMaxReasoningEffortOverLimit,
|
||||
group.FieldReasoningEffortMappings,
|
||||
group.FieldPeakRateEnabled,
|
||||
group.FieldPeakStart,
|
||||
@@ -1014,6 +1015,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
RPMLimit: g.RpmLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
|
||||
@@ -110,6 +110,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
@@ -286,6 +287,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
|
||||
@@ -392,6 +392,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"require_oauth_only": false,
|
||||
"require_privacy_set": false,
|
||||
"max_reasoning_effort": "",
|
||||
"max_reasoning_effort_over_limit": "",
|
||||
"reasoning_effort_mappings": null,
|
||||
"rpm_limit": 0,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
|
||||
@@ -309,6 +309,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err)
|
||||
}
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(platform, input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err)
|
||||
@@ -507,6 +511,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig),
|
||||
RPMLimit: input.RPMLimit,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
@@ -887,6 +892,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
}
|
||||
group.MaxReasoningEffort = maxReasoningEffort
|
||||
}
|
||||
if input.MaxReasoningEffortOverLimit != nil {
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(group.Platform, *input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
group.MaxReasoningEffortOverLimit = maxReasoningEffortOverLimit
|
||||
}
|
||||
if input.ReasoningEffortMappings != nil {
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
|
||||
@@ -152,9 +152,10 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
Enabled: source.ModelsListConfig.Enabled,
|
||||
Models: append([]string(nil), source.ModelsListConfig.Models...),
|
||||
},
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,17 +175,18 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
HaikuMappedModel: "gpt-5-mini",
|
||||
ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
|
||||
},
|
||||
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,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.sourceBindings[source.ID] = []AccountGroup{
|
||||
@@ -215,6 +216,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
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.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings)
|
||||
require.EqualValues(t, 2, duplicate.AccountCount)
|
||||
require.EqualValues(t, 2, duplicate.ActiveAccountCount)
|
||||
|
||||
@@ -275,6 +275,8 @@ type CreateGroupInput struct {
|
||||
RPMLimit int
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
|
||||
@@ -350,6 +352,8 @@ type UpdateGroupInput struct {
|
||||
RPMLimit *int
|
||||
// MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。
|
||||
MaxReasoningEffort *string
|
||||
// MaxReasoningEffortOverLimit 空字符串视为 downgrade;nil 表示未提供不改动。
|
||||
MaxReasoningEffortOverLimit *string
|
||||
// ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。
|
||||
ReasoningEffortMappings *[]ReasoningEffortMapping
|
||||
// 分组利润控制(nil 表示不修改;margin/buffer 为小数)
|
||||
|
||||
@@ -42,10 +42,11 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -55,6 +56,7 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, groupRepo.created.Platform)
|
||||
require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, groupRepo.created.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), group.ID)
|
||||
require.Equal(t, int64(2), group.AccountCount)
|
||||
@@ -90,17 +92,20 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
maxReasoningEffort := "low"
|
||||
maxReasoningEffortOverLimit := ReasoningEffortOverLimitDeny
|
||||
reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: &maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, group.Platform)
|
||||
require.Equal(t, "low", group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), clearedGroupID)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
|
||||
@@ -935,12 +935,13 @@ func TestAdminService_UpdateGroup_RejectsInvalidReasoningEffortMappings(t *testi
|
||||
|
||||
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"}},
|
||||
ID: 1,
|
||||
Name: "openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existing}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
@@ -949,6 +950,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, repo.updated.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, repo.updated.MaxReasoningEffortOverLimit)
|
||||
require.Empty(t, repo.updated.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// ReasoningEffortMappings rewrites explicit effort values before the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
|
||||
@@ -423,6 +423,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
|
||||
ModelsListConfig: apiKey.Group.ModelsListConfig,
|
||||
RPMLimit: apiKey.Group.RPMLimit,
|
||||
MaxReasoningEffort: apiKey.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: apiKey.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: apiKey.Group.PeakRateEnabled,
|
||||
PeakStart: apiKey.Group.PeakStart,
|
||||
@@ -521,6 +522,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
ModelsListConfig: snapshot.Group.ModelsListConfig,
|
||||
RPMLimit: snapshot.Group.RPMLimit,
|
||||
MaxReasoningEffort: snapshot.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: snapshot.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: snapshot.Group.PeakRateEnabled,
|
||||
PeakStart: snapshot.Group.PeakStart,
|
||||
|
||||
@@ -295,13 +295,14 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
Concurrency: 3,
|
||||
},
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -315,6 +316,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
require.NotNil(t, roundTrip.Group)
|
||||
require.Equal(t, PlatformComposite, roundTrip.Group.Platform)
|
||||
require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, roundTrip.Group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,9 @@ type Group struct {
|
||||
// MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort.
|
||||
// Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max.
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit is the access control when an explicit effort
|
||||
// exceeds the ceiling: downgrade (default) or deny.
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings rewrites explicit request values before applying the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
|
||||
|
||||
@@ -270,7 +270,16 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed {
|
||||
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody)
|
||||
if policyErr != nil {
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
if errors.As(policyErr, &overLimit) {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
|
||||
}
|
||||
return nil, policyErr
|
||||
}
|
||||
if changed {
|
||||
responsesBody = policyBody
|
||||
if responsesReq.Reasoning != nil {
|
||||
responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String()
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -80,7 +81,16 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
|
||||
chatBody = normalizedBody
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed {
|
||||
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody)
|
||||
if policyErr != nil {
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
if errors.As(policyErr, &overLimit) {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
|
||||
}
|
||||
return nil, policyErr
|
||||
}
|
||||
if changed {
|
||||
chatBody = policyBody
|
||||
if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
|
||||
reasoningEffort = &effectiveEffort
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
ctx := context.Background()
|
||||
if tt.maxPolicy != "" {
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil)
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil, "")
|
||||
}
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "")
|
||||
require.NoError(t, err)
|
||||
@@ -464,7 +464,7 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
}
|
||||
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil)
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, "")
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
const (
|
||||
maxReasoningEffortMappings = 64
|
||||
maxReasoningEffortValueLen = 64
|
||||
|
||||
// ReasoningEffortOverLimitDowngrade rewrites values above the ceiling.
|
||||
ReasoningEffortOverLimitDowngrade = "downgrade"
|
||||
// ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded.
|
||||
ReasoningEffortOverLimitDeny = "deny"
|
||||
)
|
||||
|
||||
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
@@ -21,9 +26,35 @@ type requestedReasoningEffortContextKey struct{}
|
||||
|
||||
type openAIReasoningEffortPolicy struct {
|
||||
maxEffort string
|
||||
overLimit string
|
||||
mappings []ReasoningEffortMapping
|
||||
}
|
||||
|
||||
// ReasoningEffortOverLimitError is returned when a group policy is set to deny
|
||||
// requests whose explicit reasoning effort exceeds the ceiling.
|
||||
type ReasoningEffortOverLimitError struct {
|
||||
Requested string
|
||||
Max string
|
||||
}
|
||||
|
||||
func (e *ReasoningEffortOverLimitError) Error() string {
|
||||
if e == nil {
|
||||
return "reasoning effort exceeds this group's limit"
|
||||
}
|
||||
requested := strings.TrimSpace(e.Requested)
|
||||
max := strings.TrimSpace(e.Max)
|
||||
if requested == "" && max == "" {
|
||||
return "reasoning effort exceeds this group's limit"
|
||||
}
|
||||
if requested == "" {
|
||||
return fmt.Sprintf("reasoning effort exceeds this group's limit of %q", max)
|
||||
}
|
||||
if max == "" {
|
||||
return fmt.Sprintf("reasoning effort %q exceeds this group's limit", requested)
|
||||
}
|
||||
return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max)
|
||||
}
|
||||
|
||||
// NormalizeMaxReasoningEffort validates and canonicalizes a group policy value.
|
||||
// Empty means that the group does not impose a ceiling.
|
||||
func NormalizeMaxReasoningEffort(raw string) string {
|
||||
@@ -84,6 +115,42 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error
|
||||
)
|
||||
}
|
||||
|
||||
// NormalizeMaxReasoningEffortOverLimit canonicalizes the over-limit action.
|
||||
// Empty means the historical default: automatically downgrade to the ceiling.
|
||||
func NormalizeMaxReasoningEffortOverLimit(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", ReasoningEffortOverLimitDowngrade:
|
||||
return ReasoningEffortOverLimitDowngrade
|
||||
case ReasoningEffortOverLimitDeny:
|
||||
return ReasoningEffortOverLimitDeny
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (string, error) {
|
||||
value := NormalizeMaxReasoningEffortOverLimit(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf(
|
||||
"reasoning effort over-limit action %q is not supported; allowed values: %s, %s",
|
||||
raw,
|
||||
ReasoningEffortOverLimitDowngrade,
|
||||
ReasoningEffortOverLimitDeny,
|
||||
)
|
||||
}
|
||||
if value == ReasoningEffortOverLimitDowngrade {
|
||||
return value, nil
|
||||
}
|
||||
if len(reasoningEffortValuesForPlatform(platform)) == 0 {
|
||||
return "", fmt.Errorf(
|
||||
"reasoning effort policy is only supported for platforms %q and %q",
|
||||
PlatformOpenAI,
|
||||
PlatformComposite,
|
||||
)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func reasoningEffortRank(raw string) (int, bool) {
|
||||
switch NormalizeMaxReasoningEffort(raw) {
|
||||
case "minimal":
|
||||
@@ -170,12 +237,13 @@ func RequestedReasoningEffortFromContext(ctx context.Context) *string {
|
||||
// WithOpenAIReasoningEffortPolicy binds a group policy to a request after its
|
||||
// concrete target platform has been resolved to OpenAI. The policy is copied so
|
||||
// retries and asynchronous forwarding cannot observe later slice mutations.
|
||||
func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping) context.Context {
|
||||
func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
policy := openAIReasoningEffortPolicy{
|
||||
maxEffort: maxEffort,
|
||||
overLimit: overLimit,
|
||||
mappings: append([]ReasoningEffortMapping(nil), mappings...),
|
||||
}
|
||||
return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy)
|
||||
@@ -183,15 +251,15 @@ func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mapp
|
||||
|
||||
// ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound
|
||||
// to the request. An unbound request is returned byte-for-byte unchanged.
|
||||
func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool) {
|
||||
func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool, error) {
|
||||
if ctx == nil {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy)
|
||||
if !ok {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings)
|
||||
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit)
|
||||
}
|
||||
|
||||
func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) {
|
||||
@@ -211,24 +279,32 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
|
||||
}
|
||||
maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort)
|
||||
mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings)
|
||||
overLimit := NormalizeMaxReasoningEffortOverLimit(group.MaxReasoningEffortOverLimit)
|
||||
if maxErr != nil {
|
||||
maxEffort = ""
|
||||
}
|
||||
if mappingsErr != nil {
|
||||
mappings = []ReasoningEffortMapping{}
|
||||
}
|
||||
if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) {
|
||||
overLimit = ReasoningEffortOverLimitDowngrade
|
||||
}
|
||||
group.MaxReasoningEffort = maxEffort
|
||||
group.MaxReasoningEffortOverLimit = overLimit
|
||||
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) {
|
||||
// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then either
|
||||
// caps known effort levels or rejects the request when the group is configured
|
||||
// to deny values above the ceiling. Omitted values remain untouched so
|
||||
// upstream defaults stay in control.
|
||||
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
|
||||
maxRank, hasMax := reasoningEffortRank(maxEffort)
|
||||
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
deny := hasMax && NormalizeMaxReasoningEffortOverLimit(overLimit) == ReasoningEffortOverLimitDeny
|
||||
canonicalMax := NormalizeMaxReasoningEffort(maxEffort)
|
||||
|
||||
result := body
|
||||
changed := false
|
||||
@@ -246,7 +322,10 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []
|
||||
if currentRank, recognized := reasoningEffortRank(effective); recognized {
|
||||
effective = NormalizeMaxReasoningEffort(effective)
|
||||
if hasMax && currentRank > maxRank {
|
||||
effective = NormalizeMaxReasoningEffort(maxEffort)
|
||||
if deny {
|
||||
return body, false, &ReasoningEffortOverLimitError{Requested: effective, Max: canonicalMax}
|
||||
}
|
||||
effective = canonicalMax
|
||||
}
|
||||
}
|
||||
if effective == original {
|
||||
@@ -260,5 +339,19 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []
|
||||
result = updated
|
||||
changed = true
|
||||
}
|
||||
return result, changed
|
||||
return result, changed, nil
|
||||
}
|
||||
|
||||
func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHooks) ([]byte, error) {
|
||||
if hooks == nil || (hooks.MaxReasoningEffort == "" && len(hooks.ReasoningEffortMappings) == 0) {
|
||||
return payload, nil
|
||||
}
|
||||
capped, changed, err := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings, hooks.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if changed {
|
||||
return capped, nil
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
@@ -114,30 +114,69 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) {
|
||||
require.ErrorContains(t, err, "not supported")
|
||||
}
|
||||
|
||||
func TestNormalizeMaxReasoningEffortOverLimit(t *testing.T) {
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit(""))
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit(" downgrade "))
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, NormalizeMaxReasoningEffortOverLimit("DENY"))
|
||||
require.Empty(t, NormalizeMaxReasoningEffortOverLimit("block"))
|
||||
}
|
||||
|
||||
func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) {
|
||||
value, err := normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, value)
|
||||
|
||||
value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformComposite, "deny")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, value)
|
||||
|
||||
value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, value)
|
||||
|
||||
_, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny")
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
|
||||
_, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block")
|
||||
require.ErrorContains(t, err, "not supported")
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningEffortPolicyContext(t *testing.T) {
|
||||
body := []byte(`{"reasoning":{"effort":"max"}}`)
|
||||
|
||||
unbound, changed := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body)
|
||||
unbound, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, unbound)
|
||||
|
||||
mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}}
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings)
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings, "")
|
||||
mappings[0].To = "low"
|
||||
got, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body)
|
||||
got, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
|
||||
denyCtx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, ReasoningEffortOverLimitDeny)
|
||||
_, _, err = ApplyOpenAIReasoningEffortPolicyFromContext(denyCtx, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.Equal(t, "max", overLimit.Requested)
|
||||
require.Equal(t, "medium", overLimit.Max)
|
||||
}
|
||||
|
||||
func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
max string
|
||||
mappings []ReasoningEffortMapping
|
||||
path string
|
||||
want string
|
||||
changed bool
|
||||
name string
|
||||
body string
|
||||
max string
|
||||
overLimit string
|
||||
mappings []ReasoningEffortMapping
|
||||
path string
|
||||
want string
|
||||
changed bool
|
||||
deny 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},
|
||||
@@ -152,10 +191,24 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
|
||||
{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},
|
||||
{name: "deny rejects over ceiling", body: `{"reasoning_effort":"high"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high", deny: true},
|
||||
{name: "deny keeps value at ceiling", body: `{"reasoning_effort":"low"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "low"},
|
||||
{name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true},
|
||||
{name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true},
|
||||
{name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, changed := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings)
|
||||
got, changed, err := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings, tt.overLimit)
|
||||
if tt.deny {
|
||||
require.Error(t, err)
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.body, string(got))
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.changed, changed)
|
||||
if tt.path != "" {
|
||||
require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String())
|
||||
|
||||
@@ -255,6 +255,9 @@ type OpenAIWSIngressHooks struct {
|
||||
InitialTurnStartedAt time.Time
|
||||
// MaxReasoningEffort limits explicit reasoning effort values for this WS session.
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit is the access control when an explicit effort
|
||||
// exceeds the ceiling: downgrade (default) or deny.
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings rewrites explicit effort values for this WS session.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
TurnStarted func(turn int, startedAt time.Time)
|
||||
|
||||
@@ -263,10 +263,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
)
|
||||
}
|
||||
requestedReasoningEffort := CanonicalRequestedReasoningEffort(normalized, strings.TrimSpace(values[1].String()))
|
||||
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
|
||||
if capped, changed := ApplyOpenAIReasoningEffortPolicy(normalized, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
|
||||
normalized = capped
|
||||
}
|
||||
if next, policyErr := applyOpenAIWSReasoningEffortPolicy(normalized, hooks); policyErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr)
|
||||
} else {
|
||||
normalized = next
|
||||
}
|
||||
responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized)
|
||||
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil {
|
||||
|
||||
@@ -696,10 +696,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
firstClientMessage = liteFirstMessage
|
||||
}
|
||||
originalFirstClientMessage := firstClientMessage
|
||||
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
|
||||
if capped, changed := ApplyOpenAIReasoningEffortPolicy(firstClientMessage, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
|
||||
firstClientMessage = capped
|
||||
}
|
||||
if next, policyErr := applyOpenAIWSReasoningEffortPolicy(firstClientMessage, hooks); policyErr != nil {
|
||||
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr)
|
||||
} else {
|
||||
firstClientMessage = next
|
||||
}
|
||||
requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String())
|
||||
requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String())
|
||||
@@ -1032,10 +1032,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
payload = litePayload
|
||||
}
|
||||
originalResponseCreate := payload
|
||||
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
|
||||
if capped, changed := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
|
||||
payload = capped
|
||||
}
|
||||
if next, policyErr := applyOpenAIWSReasoningEffortPolicy(payload, hooks); policyErr != nil {
|
||||
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, policyErr.Error(), policyErr)
|
||||
} else {
|
||||
payload = next
|
||||
}
|
||||
usageMeta.captureRequestedReasoningEffort(originalResponseCreate)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Per-group access control when an explicit OpenAI/Codex reasoning effort
|
||||
-- exceeds max_reasoning_effort. Existing groups keep the previous behaviour
|
||||
-- (automatically downgrade to the ceiling).
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS max_reasoning_effort_over_limit VARCHAR(20) NOT NULL DEFAULT 'downgrade';
|
||||
@@ -17,6 +17,22 @@
|
||||
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-over-limit`" class="input-label">
|
||||
{{ t("admin.groups.form.maxReasoningEffortOverLimit") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-over-limit`"
|
||||
:model-value="overLimit"
|
||||
:options="overLimitOptions"
|
||||
:aria-label="t('admin.groups.form.maxReasoningEffortOverLimit')"
|
||||
:searchable="false"
|
||||
:disabled="!maxEffort"
|
||||
@update:model-value="updateOverLimit"
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortOverLimitHint") }}</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">
|
||||
@@ -120,6 +136,8 @@ import Select from "@/components/common/Select.vue";
|
||||
import {
|
||||
createReasoningEffortMappingRow,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
reasoningEffortOverLimitDeny,
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
validateReasoningEffortMappings,
|
||||
type ReasoningEffortMappingErrorCode,
|
||||
type ReasoningEffortMappingRow,
|
||||
@@ -129,11 +147,13 @@ const props = defineProps<{
|
||||
idPrefix: string;
|
||||
platform: GroupPlatform;
|
||||
maxEffort: string;
|
||||
overLimit: string;
|
||||
mappings: ReasoningEffortMappingRow[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:maxEffort", value: string): void;
|
||||
(event: "update:overLimit", value: string): void;
|
||||
(event: "update:mappings", value: ReasoningEffortMappingRow[]): void;
|
||||
}>();
|
||||
|
||||
@@ -142,6 +162,16 @@ const showValidation = ref(false);
|
||||
const reasoningEffortOptions = computed(() =>
|
||||
reasoningEffortOptionsForPlatform(props.platform),
|
||||
);
|
||||
const overLimitOptions = computed(() => [
|
||||
{
|
||||
value: reasoningEffortOverLimitDowngrade,
|
||||
label: t("admin.groups.form.maxReasoningEffortOverLimitDowngrade"),
|
||||
},
|
||||
{
|
||||
value: reasoningEffortOverLimitDeny,
|
||||
label: t("admin.groups.form.maxReasoningEffortOverLimitDeny"),
|
||||
},
|
||||
]);
|
||||
const validationErrors = computed(() =>
|
||||
validateReasoningEffortMappings(props.mappings, props.platform),
|
||||
);
|
||||
@@ -153,6 +183,13 @@ const updateMaxEffort = (value: string | number | boolean | null) => {
|
||||
emit("update:maxEffort", asString(value));
|
||||
};
|
||||
|
||||
const updateOverLimit = (value: string | number | boolean | null) => {
|
||||
emit(
|
||||
"update:overLimit",
|
||||
asString(value) || reasoningEffortOverLimitDowngrade,
|
||||
);
|
||||
};
|
||||
|
||||
const updateMapping = (
|
||||
id: string,
|
||||
field: "from" | "to",
|
||||
|
||||
@@ -859,7 +859,11 @@ export default {
|
||||
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. For Composite groups, it applies only to requests resolved to OpenAI. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
maxReasoningEffortOverLimit: 'Over-limit access control',
|
||||
maxReasoningEffortOverLimitDowngrade: 'Automatically downgrade when over limit',
|
||||
maxReasoningEffortOverLimitDeny: 'Deny access',
|
||||
maxReasoningEffortOverLimitHint: 'Applies after a ceiling is set. Downgrade rewrites values above the ceiling to the ceiling. Deny rejects the request.',
|
||||
reasoningEffortMappings: 'Reasoning effort mappings',
|
||||
addReasoningEffortMapping: 'Add mapping',
|
||||
removeReasoningEffortMapping: 'Remove mapping',
|
||||
|
||||
@@ -841,7 +841,11 @@ export default {
|
||||
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
|
||||
maxReasoningEffort: '推理强度上限',
|
||||
maxReasoningEffortUnlimited: '不限制(跟随请求)',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
maxReasoningEffortOverLimit: '超限访问控制',
|
||||
maxReasoningEffortOverLimitDowngrade: '超过上限时自动降档',
|
||||
maxReasoningEffortOverLimitDeny: '拒绝访问',
|
||||
maxReasoningEffortOverLimitHint: '设置上限后生效。自动降档会将超过上限的请求改写为上限值后转发;拒绝访问则直接返回错误。',
|
||||
reasoningEffortMappings: '推理强度映射',
|
||||
addReasoningEffortMapping: '添加映射',
|
||||
removeReasoningEffortMapping: '删除映射',
|
||||
|
||||
@@ -557,6 +557,7 @@ export interface Group {
|
||||
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
|
||||
max_reasoning_effort_over_limit?: string // downgrade (default) or deny when over the ceiling
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
is_exclusive: boolean
|
||||
status: 'active' | 'inactive'
|
||||
@@ -818,6 +819,7 @@ export interface CreateGroupRequest {
|
||||
model_routing_enabled?: boolean
|
||||
rpm_limit?: number
|
||||
max_reasoning_effort?: string
|
||||
max_reasoning_effort_over_limit?: string
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
@@ -880,6 +882,7 @@ export interface UpdateGroupRequest {
|
||||
model_routing_enabled?: boolean
|
||||
rpm_limit?: number
|
||||
max_reasoning_effort?: string
|
||||
max_reasoning_effort_over_limit?: string
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
|
||||
@@ -631,6 +631,7 @@
|
||||
id-prefix="create-group-reasoning"
|
||||
:platform="createForm.platform"
|
||||
v-model:max-effort="createForm.max_reasoning_effort"
|
||||
v-model:over-limit="createForm.max_reasoning_effort_over_limit"
|
||||
v-model:mappings="createForm.reasoning_effort_mappings"
|
||||
/>
|
||||
<div
|
||||
@@ -2361,6 +2362,7 @@
|
||||
id-prefix="edit-group-reasoning"
|
||||
:platform="editForm.platform"
|
||||
v-model:max-effort="editForm.max_reasoning_effort"
|
||||
v-model:over-limit="editForm.max_reasoning_effort_over_limit"
|
||||
v-model:mappings="editForm.reasoning_effort_mappings"
|
||||
/>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
@@ -4495,8 +4497,10 @@ import {
|
||||
} from "./groupsProfitControl";
|
||||
import {
|
||||
normalizeReasoningEffortForPlatform,
|
||||
normalizeReasoningEffortOverLimit,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
supportsReasoningEffortPolicyPlatform,
|
||||
type ReasoningEffortMappingRow,
|
||||
} from "./groupsReasoningEffort";
|
||||
@@ -5105,6 +5109,7 @@ const createForm = reactive({
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
max_reasoning_effort: "",
|
||||
max_reasoning_effort_over_limit: reasoningEffortOverLimitDowngrade,
|
||||
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
|
||||
});
|
||||
|
||||
@@ -5467,6 +5472,7 @@ const editForm = reactive({
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
max_reasoning_effort: "",
|
||||
max_reasoning_effort_over_limit: reasoningEffortOverLimitDowngrade,
|
||||
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
|
||||
});
|
||||
|
||||
@@ -5907,6 +5913,7 @@ const closeCreateModal = () => {
|
||||
createForm.copy_accounts_from_group_ids = [];
|
||||
createForm.rpm_limit = 0;
|
||||
createForm.max_reasoning_effort = "";
|
||||
createForm.max_reasoning_effort_over_limit = reasoningEffortOverLimitDowngrade;
|
||||
createForm.reasoning_effort_mappings = [];
|
||||
createReasoningEffortPolicyRef.value?.resetValidation();
|
||||
resetModelsListState(createModelsListState);
|
||||
@@ -6177,6 +6184,9 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
group.platform,
|
||||
group.max_reasoning_effort,
|
||||
);
|
||||
editForm.max_reasoning_effort_over_limit = normalizeReasoningEffortOverLimit(
|
||||
group.max_reasoning_effort_over_limit,
|
||||
);
|
||||
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
group.reasoning_effort_mappings,
|
||||
group.platform,
|
||||
@@ -6198,6 +6208,7 @@ const closeEditModal = () => {
|
||||
showEditModal.value = false;
|
||||
editingGroup.value = null;
|
||||
editForm.max_reasoning_effort = "";
|
||||
editForm.max_reasoning_effort_over_limit = reasoningEffortOverLimitDowngrade;
|
||||
editForm.reasoning_effort_mappings = [];
|
||||
editReasoningEffortPolicyRef.value?.resetValidation();
|
||||
editModelRoutingRules.value = [];
|
||||
@@ -6673,6 +6684,13 @@ watch(
|
||||
newVal,
|
||||
createForm.max_reasoning_effort,
|
||||
);
|
||||
createForm.max_reasoning_effort_over_limit = supportsReasoningEffortPolicyPlatform(
|
||||
newVal,
|
||||
)
|
||||
? normalizeReasoningEffortOverLimit(
|
||||
createForm.max_reasoning_effort_over_limit,
|
||||
)
|
||||
: reasoningEffortOverLimitDowngrade;
|
||||
createForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
reasoningEffortMappingsToAPI(createForm.reasoning_effort_mappings),
|
||||
newVal,
|
||||
@@ -6723,6 +6741,13 @@ watch(
|
||||
newVal,
|
||||
editForm.max_reasoning_effort,
|
||||
);
|
||||
editForm.max_reasoning_effort_over_limit = supportsReasoningEffortPolicyPlatform(
|
||||
newVal,
|
||||
)
|
||||
? normalizeReasoningEffortOverLimit(
|
||||
editForm.max_reasoning_effort_over_limit,
|
||||
)
|
||||
: reasoningEffortOverLimitDowngrade;
|
||||
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
|
||||
reasoningEffortMappingsToAPI(editForm.reasoning_effort_mappings),
|
||||
newVal,
|
||||
|
||||
@@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortForPlatform,
|
||||
normalizeReasoningEffortOverLimit,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
reasoningEffortOverLimitDeny,
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
supportsReasoningEffortPolicyPlatform,
|
||||
validateReasoningEffortMappings,
|
||||
} from "../groupsReasoningEffort";
|
||||
@@ -89,4 +92,22 @@ describe("groupsReasoningEffort", () => {
|
||||
[row.id]: { from: "unsupportedFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes over-limit access control to downgrade or deny", () => {
|
||||
expect(normalizeReasoningEffortOverLimit(undefined)).toBe(
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
);
|
||||
expect(normalizeReasoningEffortOverLimit("")).toBe(
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
);
|
||||
expect(normalizeReasoningEffortOverLimit(" downgrade ")).toBe(
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
);
|
||||
expect(normalizeReasoningEffortOverLimit("DENY")).toBe(
|
||||
reasoningEffortOverLimitDeny,
|
||||
);
|
||||
expect(normalizeReasoningEffortOverLimit("block")).toBe(
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ const openAIReasoningEffortValues = [
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
export const reasoningEffortOverLimitDowngrade = "downgrade";
|
||||
export const reasoningEffortOverLimitDeny = "deny";
|
||||
|
||||
const reasoningEffortValuesForPlatform = (
|
||||
platform: GroupPlatform,
|
||||
): readonly string[] =>
|
||||
@@ -41,6 +44,14 @@ export function normalizeReasoningEffortForPlatform(
|
||||
: "";
|
||||
}
|
||||
|
||||
export function normalizeReasoningEffortOverLimit(
|
||||
value: string | null | undefined,
|
||||
): string {
|
||||
return value?.trim().toLowerCase() === reasoningEffortOverLimitDeny
|
||||
? reasoningEffortOverLimitDeny
|
||||
: reasoningEffortOverLimitDowngrade;
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow extends ReasoningEffortMapping {
|
||||
id: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user