From aa7a811e66489e132d495eb5f7eb1425fcdb61b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 1 Sep 2026 10:15:45 +0800 Subject: [PATCH] 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. --- backend/ent/group.go | 13 +- backend/ent/group/group.go | 12 ++ backend/ent/group/where.go | 70 +++++++++++ backend/ent/group_create.go | 70 +++++++++++ backend/ent/group_update.go | 44 +++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++- backend/ent/runtime/runtime.go | 14 ++- backend/ent/schema/group.go | 4 + .../internal/handler/admin/group_handler.go | 6 + .../internal/handler/composite_platform.go | 31 +++-- .../handler/composite_platform_test.go | 23 +++- backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 + .../handler/openai_chat_completions.go | 5 +- .../handler/openai_gateway_handler.go | 20 +-- backend/internal/handler/ops_error_logger.go | 1 + .../internal/handler/ops_error_logger_test.go | 15 +++ backend/internal/repository/api_key_repo.go | 2 + backend/internal/repository/group_repo.go | 2 + backend/internal/server/api_contract_test.go | 1 + backend/internal/service/admin_group.go | 12 ++ .../internal/service/admin_group_duplicate.go | 7 +- .../service/admin_group_duplicate_test.go | 24 ++-- backend/internal/service/admin_service.go | 4 + .../admin_service_composite_group_test.go | 19 +-- .../service/admin_service_group_test.go | 14 ++- .../internal/service/api_key_auth_cache.go | 2 + .../service/api_key_auth_cache_impl.go | 2 + .../service/api_key_service_cache_test.go | 16 +-- backend/internal/service/group.go | 3 + .../service/openai_gateway_messages.go | 11 +- .../openai_gateway_messages_chat_fallback.go | 12 +- ...nai_gateway_messages_chat_fallback_test.go | 4 +- .../service/openai_reasoning_effort_policy.go | 117 ++++++++++++++++-- .../openai_reasoning_effort_policy_test.go | 75 +++++++++-- .../internal/service/openai_ws_forwarder.go | 3 + .../service/openai_ws_forwarder_ingress.go | 8 +- .../openai_ws_v2_passthrough_adapter.go | 16 +-- .../232_group_reasoning_effort_over_limit.sql | 5 + .../group/ReasoningEffortPolicyFields.vue | 37 ++++++ .../src/i18n/locales/en/admin/overview.ts | 6 +- .../src/i18n/locales/zh/admin/overview.ts | 6 +- frontend/src/types/index.ts | 3 + frontend/src/views/admin/GroupsView.vue | 25 ++++ .../__tests__/groupsReasoningEffort.spec.ts | 21 ++++ .../src/views/admin/groupsReasoningEffort.ts | 11 ++ 47 files changed, 751 insertions(+), 105 deletions(-) create mode 100644 backend/migrations/232_group_reasoning_effort_over_limit.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 8f30c26470..f08c511391 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -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(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 5ec04d32ae..9e3a304f04 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -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() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 685a48a922..0ed5281982 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -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)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index e5e92ccd3f..e7a0837393 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -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) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 41e06b4471..d1dbb8975b 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -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) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 270eecd87a..067d3cca0e 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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)"}}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 54d284ad97..03387ca839 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -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 diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 088b876b96..2f76855b5d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -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() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index f195ac51db..c6d93b70e8 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -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"}). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 2fcd69a9b2..97a84a4c54 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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, }) diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index fad5e12b56..49e87cb332 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -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)) } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 4d2c0f583f..73db844e7e 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -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) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1c02cd69a3..2afe2243de 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 5ac667e171..d0b6ba78bd 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index be75ab8dea..e74b11b9b6 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -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) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 40763b1976..c9fa07a14f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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) diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 41a1ea0583..add82f77bf 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -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")) || diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 640e8efa91..bd29d103bf 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -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", diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 6e517f4524..bfe810f5ba 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -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, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index b5de46353d..69a8ac0eed 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -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). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index c7a16945f0..d6a96e5ecf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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", diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 9e29c30eaa..728232044d 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -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 { diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index deda6d3822..0eb34a8db8 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -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...), } } diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index f4a7bef2c5..9e7833b177 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -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) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index e48eea51c1..d052eaa900 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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 为小数) diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index f1c00482b1..5463f9e513 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -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) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 72c5e9a0f7..b6d147af0e 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -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) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 71e0318dc0..53f4e9b2c2 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -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"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 3c95141a47..7c52322cea 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -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, diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 31f62adfee..ff5cffefef 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -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) } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bf0440afc3..1c7b7ba9b3 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -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 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index cfd0649154..6f9fc398fe 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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() diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 9a9dfc611f..c4fcdd772f 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -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 diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go index 43c453376c..60594026ab 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -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) diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 17432e49c7..97ccb28517 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -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 } diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index 2ef836cc31..e504471f1e 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -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()) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 0994446624..b5a466949f 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -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) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b6af8fc06c..e57c2dd899 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -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 { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 95de10df71..715a747918 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -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) } diff --git a/backend/migrations/232_group_reasoning_effort_over_limit.sql b/backend/migrations/232_group_reasoning_effort_over_limit.sql new file mode 100644 index 0000000000..3b7aec986b --- /dev/null +++ b/backend/migrations/232_group_reasoning_effort_over_limit.sql @@ -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'; diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index ba363d7451..99ac1fdc3c 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -17,6 +17,22 @@

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

+
+ +