feat(groups): add deny-or-downgrade control for reasoning effort ceiling

OpenAI/Composite groups can now reject requests that exceed the max
reasoning effort instead of always rewriting them down to the ceiling.
Existing groups keep the previous auto-downgrade behavior.
This commit is contained in:
刘佳林 committed 2026-09-01 10:15:45 +08:00
1 parent 2ac784c51a
commit aa7a811e66
47 files changed
+751 -105

No files matched your search

@@ -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,
})
+20 -11
View File
@@ -1,6 +1,7 @@
package handler
import (
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -59,17 +60,17 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
return apiKey.Group.Platform
}
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
if apiKey == nil || apiKey.Group == nil {
return "", nil, false
return "", nil, "", false
}
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, false
return "", nil, "", false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
return "", nil, false
return "", nil, "", false
}
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
}
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
@@ -100,13 +101,21 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request
result.RequestedReasoningEffort = requested
}
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false
return body, false, nil
}
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
}
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
if c == nil || err == nil || write == nil {
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
write(c, http.StatusForbidden, "permission_error", err.Error())
}
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
@@ -121,9 +130,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
return
}
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return
}
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
}
@@ -117,7 +117,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
@@ -125,7 +126,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
require.Equal(t, "max", *requested)
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
@@ -133,14 +135,27 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, omitted)
denyGroup := *group
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
denyAPIKey := &service.APIKey{Group: &denyGroup}
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
require.Error(t, err)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
+1
View File
@@ -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,
+2
View File
@@ -153,6 +153,8 @@ type Group struct {
RPMLimit int `json:"rpm_limit"`
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
body = cappedBody
}
reqStream, ok := parseOpenAICompatibleStream(body)
@@ -435,7 +435,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
body = cappedBody
}
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
@@ -2530,7 +2533,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.Int("candidate_count", scheduleDecision.CandidateCount),
)
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
var requestPayloadHash string
var turnStartsMu sync.Mutex
turnStarts := make(map[int]time.Time, 4)
@@ -2557,12 +2560,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
InitialTurnStartedAt: firstTurnStartedAt,
MaxReasoningEffort: maxReasoningEffort,
ReasoningEffortMappings: reasoningEffortMappings,
TurnStarted: recordTurnStart,
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
InitialTurnStartedAt: firstTurnStartedAt,
MaxReasoningEffort: maxReasoningEffort,
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
ReasoningEffortMappings: reasoningEffortMappings,
TurnStarted: recordTurnStart,
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
c.Set(securityAuditWSTurnContextKey, turn)
service.BeginOpsStreamTurn(c, turn)
@@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
strings.Contains(msg, "this group is restricted to claude code clients") ||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
strings.Contains(msg, "image generation is not enabled for this group") ||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
strings.Contains(msg, "token counting is not supported for this platform") ||
strings.Contains(msg, "images api is not supported for this platform") ||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
wantErrType: "permission_error",
wantPhase: "request",
},
{
name: "group reasoning effort over limit deny",
errType: "permission_error",
message: `reasoning effort "high" exceeds this group's limit of "low"`,
code: "",
status: http.StatusForbidden,
wantErrType: "permission_error",
wantPhase: "request",
},
{
name: "route token counting platform unsupported",
errType: "not_found_error",
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
code: "403",
status: http.StatusForbidden,
},
{
name: "provider reasoning effort over limit shaped error",
message: `reasoning effort "high" exceeds this group's limit of "low"`,
code: "403",
status: http.StatusForbidden,
},
{
name: "provider token counting unsupported shaped error",
message: "Token counting is not supported for this platform",