From f62ec2e4a0f7ad7f92359971ef52c80bfb974b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Mon, 7 Sep 2026 10:12:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(groups):=20=E6=8E=A8=E7=90=86=E5=BC=BA?= =?UTF-8?q?=E5=BA=A6=E6=98=A0=E5=B0=84=E8=BD=AC=E5=8F=91=E5=80=BC=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组推理强度映射的转发值新增 deny。命中对应请求档位时直接拒绝请求, 返回 403 permission_error,而不是改写后转发。 --- backend/internal/domain/reasoning_effort.go | 3 +- .../handler/composite_platform_test.go | 17 +++ .../service/openai_gateway_messages.go | 5 +- .../openai_gateway_messages_chat_fallback.go | 6 +- .../service/openai_reasoning_effort_policy.go | 78 ++++++++++++-- .../openai_reasoning_effort_policy_test.go | 100 ++++++++++++++++-- .../group/ReasoningEffortPolicyFields.vue | 14 ++- .../ReasoningEffortPolicyFields.spec.ts | 9 ++ .../src/i18n/locales/en/admin/overview.ts | 3 +- .../src/i18n/locales/zh/admin/overview.ts | 3 +- .../__tests__/groupsReasoningEffort.spec.ts | 38 +++++++ .../src/views/admin/groupsReasoningEffort.ts | 27 ++++- 12 files changed, 274 insertions(+), 29 deletions(-) diff --git a/backend/internal/domain/reasoning_effort.go b/backend/internal/domain/reasoning_effort.go index 88f4263fc2..64ecf8ad62 100644 --- a/backend/internal/domain/reasoning_effort.go +++ b/backend/internal/domain/reasoning_effort.go @@ -7,7 +7,8 @@ const ( ) // ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort -// value to another before the group ceiling is applied. +// value to another before the group ceiling is applied. To may also be "deny" +// to reject the request when the matching source value is present. // // Model and MatchType optionally scope the rewrite to a request model: // exact matches the full model id, prefix/suffix match a model-id affix. diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index d9a642ce06..af5726e217 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -152,6 +152,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { var overLimit *service.ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + mappingDenyGroup := *group + mappingDenyGroup.MaxReasoningEffort = "" + mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{ + {From: "max", To: service.ReasoningEffortMappingDeny}, + } + mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup} + mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI)) + _, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body) + require.Error(t, err) + require.False(t, changed) + var mappingDenied *service.ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.Equal(t, "max", mappingDenied.Requested) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + 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)) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index e755548a7d..7f3144752d 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -272,10 +272,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 270762a0dc..928e3fa982 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -83,10 +82,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 0d1ad0dc5c..907025597b 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" @@ -19,6 +20,9 @@ const ( ReasoningEffortOverLimitDowngrade = "downgrade" // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded. ReasoningEffortOverLimitDeny = "deny" + // ReasoningEffortMappingDeny is a mapping target that rejects the request + // when the matching source reasoning effort is present. + ReasoningEffortMappingDeny = "deny" ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} @@ -31,6 +35,17 @@ func normalizeReasoningEffortMappingSource(raw string) string { return NormalizeMaxReasoningEffort(raw) } +func normalizeReasoningEffortMappingTarget(raw string) string { + if isReasoningEffortMappingDeny(raw) { + return ReasoningEffortMappingDeny + } + return NormalizeMaxReasoningEffort(raw) +} + +func isReasoningEffortMappingDeny(raw string) bool { + return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny) +} + type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -65,6 +80,37 @@ func (e *ReasoningEffortOverLimitError) Error() string { return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max) } +// ReasoningEffortMappingDeniedError is returned when a group mapping target is +// set to deny the explicit reasoning effort on the request. +type ReasoningEffortMappingDeniedError struct { + Requested string +} + +func (e *ReasoningEffortMappingDeniedError) Error() string { + if e == nil { + return "reasoning effort is denied by this group's mapping policy" + } + requested := strings.TrimSpace(e.Requested) + if requested == "" { + return "reasoning effort is denied by this group's mapping policy" + } + return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested) +} + +// IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort +// policy rejection (ceiling deny or mapping deny). +func IsReasoningEffortPolicyDenied(err error) bool { + if err == nil { + return false + } + var overLimit *ReasoningEffortOverLimitError + if errors.As(err, &overLimit) { + return true + } + var mappingDenied *ReasoningEffortMappingDeniedError + return errors.As(err, &mappingDenied) +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -298,7 +344,7 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp seen := make(map[string]struct{}, len(raw)) for i, mapping := range raw { from := normalizeReasoningEffortMappingSource(mapping.From) - to := NormalizeMaxReasoningEffort(mapping.To) + to := normalizeReasoningEffortMappingTarget(mapping.To) if from == "" || to == "" { return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) } @@ -315,8 +361,18 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp i+1, PlatformOpenAI, PlatformComposite, ) } - if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { - return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + if to != ReasoningEffortMappingDeny { + if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + } + } else if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return nil, fmt.Errorf( + "reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q", + i+1, + PlatformAnthropic, + PlatformOpenAI, + PlatformComposite, + ) } model := strings.TrimSpace(mapping.Model) if len(model) > maxReasoningEffortModelLen { @@ -439,9 +495,10 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { // ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) 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. It understands both OpenAI and Anthropic request -// field shapes. +// deny values above the ceiling. A mapping whose target is deny rejects the +// request when that source value is present. Omitted values remain untouched +// so upstream defaults stay in control. It understands both OpenAI and +// Anthropic request field shapes. func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { @@ -463,7 +520,14 @@ func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []Reason continue } - effective, _ := mapReasoningEffort(original, mappings, requestModel) + effective, mapped := mapReasoningEffort(original, mappings, requestModel) + if mapped && isReasoningEffortMappingDeny(effective) { + requested := normalizeReasoningEffortMappingSource(original) + if requested == "" { + requested = original + } + return body, false, &ReasoningEffortMappingDeniedError{Requested: requested} + } if currentRank, recognized := reasoningEffortRank(effective); recognized { effective = NormalizeMaxReasoningEffort(effective) if hasMax && currentRank > maxRank { diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index f3697bbe86..62cc394c3d 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -167,6 +167,27 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { require.ErrorContains(t, err, "empty or unknown") }) + t.Run("allows deny as a mapping target", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformComposite, PlatformAnthropic} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " XHIGH ", To: " DENY "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, got) + } + + got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ + {From: "none", To: "deny"}, + {From: "max", To: "deny", MatchType: "prefix", Model: "gpt"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "none", To: ReasoningEffortMappingDeny}, + {From: "max", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}, + }, got) + + _, err = NormalizeReasoningEffortMappings(PlatformGemini, []ReasoningEffortMapping{{From: "high", To: "deny"}}) + require.ErrorContains(t, err, "only supported for platforms") + }) + t.Run("supports Anthropic values except minimal", func(t *testing.T) { got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) require.NoError(t, err) @@ -255,15 +276,16 @@ func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { - name string - body string - max string - overLimit string - mappings []ReasoningEffortMapping - path string - want string - changed bool - deny bool + name string + body string + max string + overLimit string + mappings []ReasoningEffortMapping + path string + want string + changed bool + deny bool + mappingDeny 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}, @@ -419,6 +441,41 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {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"}, + {name: "mapping deny rejects matching nested effort", body: `{"reasoning":{"effort":"xhigh"}}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, path: "reasoning.effort", want: "xhigh", mappingDeny: true}, + {name: "mapping deny rejects matching flat effort", body: `{"reasoning_effort":"X-HIGH"}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: " DENY "}}, path: "reasoning_effort", want: "X-HIGH", mappingDeny: true}, + {name: "mapping deny rejects Anthropic output config", body: `{"output_config":{"effort":"max"}}`, mappings: []ReasoningEffortMapping{{From: "max", To: ReasoningEffortMappingDeny}}, path: "output_config.effort", want: "max", mappingDeny: true}, + {name: "mapping deny rejects none source", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: ReasoningEffortMappingDeny}}, path: "reasoning_effort", want: "none", mappingDeny: true}, + { + name: "mapping deny respects model scope", + body: `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, + { + name: "mapping deny skips non matching model", + body: `{"model":"gpt-5.6-terra","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + }, + { + name: "mapping deny leaves other efforts unchanged", + body: `{"reasoning_effort":"high"}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning_effort", + want: "high", + }, + { + name: "mapping deny takes precedence over ceiling rewrite", + body: `{"reasoning":{"effort":"xhigh"}}`, + max: "medium", + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -427,6 +484,20 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { require.Error(t, err) var overLimit *ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.False(t, changed) + require.Equal(t, tt.body, string(got)) + return + } + if tt.mappingDeny { + require.Error(t, err) + var mappingDenied *ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + if requested := normalizeReasoningEffortMappingSource(tt.want); requested != "" { + require.Equal(t, requested, mappingDenied.Requested) + } require.False(t, changed) require.Equal(t, tt.body, string(got)) return @@ -442,3 +513,14 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }) } } + +func TestReasoningEffortMappingDeniedError(t *testing.T) { + t.Parallel() + + err := &ReasoningEffortMappingDeniedError{Requested: "xhigh"} + require.Equal(t, `reasoning effort "xhigh" is denied by this group's mapping policy`, err.Error()) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.True(t, IsReasoningEffortPolicyDenied(&ReasoningEffortOverLimitError{Requested: "max", Max: "low"})) + require.False(t, IsReasoningEffortPolicyDenied(nil)) + require.False(t, IsReasoningEffortPolicyDenied(context.Canceled)) +} diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index b16107162f..ba9755db89 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -164,7 +164,7 @@