From d60f4e442baeadb66d8dc693f306f9de2ed8ca6d Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 1 Aug 2026 21:15:38 +0800 Subject: [PATCH] feat(composite): enforce reasoning effort policy --- .../internal/handler/composite_platform.go | 42 +++++++++++++++++ .../handler/composite_platform_test.go | 40 +++++++++++++++++ .../handler/openai_chat_completions.go | 6 +-- .../handler/openai_gateway_handler.go | 14 ++---- .../admin_service_composite_group_test.go | 18 ++++++-- .../service/api_key_service_cache_test.go | 5 ++- .../service/openai_gateway_messages.go | 9 ++++ .../openai_gateway_messages_chat_fallback.go | 9 ++++ ...nai_gateway_messages_chat_fallback_test.go | 23 ++++++++-- .../service/openai_reasoning_effort_policy.go | 45 +++++++++++++++++-- .../openai_reasoning_effort_policy_test.go | 43 +++++++++++++----- .../src/i18n/locales/en/admin/overview.ts | 2 +- .../src/i18n/locales/zh/admin/overview.ts | 2 +- frontend/src/views/admin/GroupsView.vue | 9 ++-- .../__tests__/groupsReasoningEffort.spec.ts | 21 ++++++--- .../src/views/admin/groupsReasoningEffort.ts | 10 ++++- 16 files changed, 250 insertions(+), 48 deletions(-) diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index bb001a8b22..0d57c709c6 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -1,9 +1,12 @@ package handler import ( + "strings" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" ) func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) { @@ -55,3 +58,42 @@ 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) { + if apiKey == nil || apiKey.Group == nil { + return "", nil, false + } + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + return "", nil, false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { + return "", nil, false + } + return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true +} + +func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) { + maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return body, false + } + return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings) +} + +func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { + if c == nil || c.Request == nil { + return + } + // The Messages bridge synthesizes a default OpenAI effort when + // output_config.effort is omitted. Bind the group policy only for an + // explicit client value so the ceiling does not alter that default. + effort := gjson.GetBytes(body, "output_config.effort") + if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" { + return + } + maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return + } + c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings)) +} diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index a879f9ffe5..2fa79c538b 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -77,6 +77,46 @@ func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t * require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick")) } +func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { + gin.SetMode(gin.TestMode) + group := &service.Group{ + Platform: service.PlatformComposite, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + } + apiKey := &service.APIKey{Group: group} + body := []byte(`{"reasoning":{"effort":"max"}}`) + + 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) + require.True(t, changed) + require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got)) + + bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`)) + bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body) + require.True(t, changed) + require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound)) + + omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + 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) + require.False(t, changed) + require.Equal(t, body, omitted) + + 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) + require.False(t, changed) + require.Equal(t, body, got) +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 39c12253b1..9598c1ebeb 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -80,10 +80,8 @@ 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 apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { - body = cappedBody - } + if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 8602a684cb..35f6db3737 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -312,10 +312,8 @@ 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 apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { - body = cappedBody - } + if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) @@ -932,6 +930,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } + bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() @@ -1813,12 +1812,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.Int("candidate_count", scheduleDecision.CandidateCount), ) - maxReasoningEffort := "" - var reasoningEffortMappings []service.ReasoningEffortMapping - if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - maxReasoningEffort = apiKey.Group.MaxReasoningEffort - reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings - } + maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) var requestPayloadHash string // Passthrough rejects overlapping response.create frames, so one immutable // turn-tagged slot preserves the exact mapping used for the in-flight request. diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 73095abc5f..5530720f5e 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -41,14 +41,20 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te svc := &adminServiceImpl{groupRepo: groupRepo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "Composite", - Platform: PlatformComposite, - RateMultiplier: 1, + Name: "Composite", + Platform: PlatformComposite, + RateMultiplier: 1, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, CopyAccountsFromGroupIDs: []int64{10, 20, 10}, }) require.NoError(t, err) require.Equal(t, PlatformComposite, groupRepo.created.Platform) + require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort) + 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) require.ElementsMatch(t, []int64{10, 20}, copiedFrom) @@ -82,13 +88,19 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te }, } svc := &adminServiceImpl{groupRepo: groupRepo} + maxReasoningEffort := "low" + reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}} group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{ + MaxReasoningEffort: &maxReasoningEffort, + 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, reasoningEffortMappings, group.ReasoningEffortMappings) require.Equal(t, int64(99), clearedGroupID) require.ElementsMatch(t, []int64{10, 20}, copiedFrom) require.Equal(t, int64(99), boundGroupID) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 75f4a0de9c..31f62adfee 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -296,8 +296,8 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi }, Group: &Group{ ID: groupID, - Name: "openai", - Platform: PlatformOpenAI, + Name: "composite", + Platform: PlatformComposite, Status: StatusActive, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1, @@ -313,6 +313,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi require.NotNil(t, roundTrip) require.NotNil(t, roundTrip.Group) + require.Equal(t, PlatformComposite, roundTrip.Group.Platform) require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort) require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index a5836a90d3..066fc82cec 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -18,6 +18,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -246,6 +247,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } } + if account.Platform == PlatformOpenAI { + if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed { + responsesBody = policyBody + if responsesReq.Reasoning != nil { + responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String() + } + } + } // 4c. Apply OpenAI fast policy (may filter service_tier or block the request). // Mirrors the Claude anthropic-beta "fast-mode-2026-02-01" filter, but keyed diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index c66077a0bf..e2929be92e 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -78,6 +79,14 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { chatBody = normalizedBody } + if account.Platform == PlatformOpenAI { + if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed { + chatBody = policyBody + if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" { + reasoningEffort = &effectiveEffort + } + } + } // Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody // is intentionally skipped: Anthropic Messages bodies carry no service_tier, // so the converted Chat Completions body never contains one and the policy 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 0ca6b5f106..dd5c1ed9c0 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -54,7 +54,16 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo mapped string effortJSON string wantEffort string + maxPolicy string }{ + { + name: "policy caps converted effort", + model: "gpt-5.6-luna", + mapped: "gpt-5.6-luna", + effortJSON: `,"output_config":{"effort":"max"}`, + wantEffort: "medium", + maxPolicy: "medium", + }, { name: "GPT56 max", model: "luna", @@ -103,7 +112,11 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo account.Credentials["model_mapping"] = map[string]any{tt.model: tt.mapped} svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, err := svc.ForwardAsAnthropic(context.Background(), c, account, []byte(body), "", "") + ctx := context.Background() + if tt.maxPolicy != "" { + ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil) + } + result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "") require.NoError(t, err) require.NotNil(t, result) require.Equal(t, tt.mapped, gjson.GetBytes(upstream.lastBody, "model").String()) @@ -420,7 +433,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsStreamReadErrorSkipsFinalize(t * func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":"high"},"stream":false}`) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) @@ -449,12 +462,16 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint( openai_compat.ExtraKeyResponsesSupported: true, } - result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil) + result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "") require.NoError(t, err) require.NotNil(t, result) require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/responses"), "responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String()) require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "medium", *result.ReasoningEffort) require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator")) diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 92048430d5..73453c2260 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "strings" @@ -15,6 +16,13 @@ const ( var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +type openAIReasoningEffortPolicyContextKey struct{} + +type openAIReasoningEffortPolicy struct { + maxEffort string + mappings []ReasoningEffortMapping +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -41,7 +49,7 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI { + if platform != PlatformOpenAI && platform != PlatformComposite { return nil } return openAIReasoningEffortValues @@ -54,7 +62,11 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { - return "", fmt.Errorf("reasoning effort policy is only supported for platform %q", PlatformOpenAI) + return "", fmt.Errorf( + "reasoning effort policy is only supported for platforms %q and %q", + PlatformOpenAI, + PlatformComposite, + ) } value := NormalizeMaxReasoningEffort(raw) @@ -91,7 +103,7 @@ func reasoningEffortRank(raw string) (int, bool) { } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI groups. +// fixed effort values supported by OpenAI routes. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { if len(raw) > maxReasoningEffortMappings { return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings) @@ -124,6 +136,33 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp return normalized, nil } +// 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 { + if ctx == nil { + ctx = context.Background() + } + policy := openAIReasoningEffortPolicy{ + maxEffort: maxEffort, + mappings: append([]ReasoningEffortMapping(nil), mappings...), + } + return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy) +} + +// 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) { + if ctx == nil { + return body, false + } + policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy) + if !ok { + return body, false + } + return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings) +} + func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) { value := strings.TrimSpace(raw) canonical := NormalizeMaxReasoningEffort(value) diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index a7ab7b2ad5..4b501f48db 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -28,15 +29,17 @@ func TestNormalizeMaxReasoningEffort(t *testing.T) { func TestNormalizeReasoningEffortMappings(t *testing.T) { t.Run("canonicalizes fixed OpenAI values", func(t *testing.T) { - got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ - {From: " MAX ", To: " x-high "}, - {From: "minimal", To: "high"}, - }) - require.NoError(t, err) - require.Equal(t, []ReasoningEffortMapping{ - {From: "max", To: "xhigh"}, - {From: "minimal", To: "high"}, - }, got) + for _, platform := range []string{PlatformOpenAI, PlatformComposite} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{ + {From: " MAX ", To: " x-high "}, + {From: "minimal", To: "high"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + {From: "minimal", To: "high"}, + }, got) + } }) t.Run("rejects empty values", func(t *testing.T) { @@ -55,7 +58,7 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platform \"openai\"") + require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") } _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) @@ -70,16 +73,34 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { value, err := normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "max") require.NoError(t, err) require.Equal(t, "max", value) + value, err = normalizeMaxReasoningEffortForPlatform(PlatformComposite, "max") + require.NoError(t, err) + require.Equal(t, "max", value) for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platform \"openai\"") + require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") require.ErrorContains(t, err, "not supported") } +func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { + body := []byte(`{"reasoning":{"effort":"max"}}`) + + unbound, changed := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body) + require.False(t, changed) + require.Equal(t, body, unbound) + + mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}} + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings) + mappings[0].To = "low" + got, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + require.True(t, changed) + require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String()) +} + func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { name string diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 67887a9f45..2db008bea8 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -850,7 +850,7 @@ export default { rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).', maxReasoningEffort: 'Max reasoning effort', maxReasoningEffortUnlimited: 'Unlimited (follow request)', - maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', + maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', reasoningEffortMappings: 'Reasoning effort mappings', addReasoningEffortMapping: 'Add mapping', removeReasoningEffortMapping: 'Remove mapping', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 112577b894..bfbacd5fd0 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -832,7 +832,7 @@ export default { rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', maxReasoningEffort: '推理强度上限', maxReasoningEffortUnlimited: '不限制(跟随请求)', - maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', + maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', reasoningEffortMappings: '推理强度映射', addReasoningEffortMapping: '添加映射', removeReasoningEffortMapping: '删除映射', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 54375581b9..32f610a921 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -610,7 +610,7 @@

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

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

{ return; } if ( - createForm.platform === "openai" && + supportsReasoningEffortPolicyPlatform(createForm.platform) && createReasoningEffortPolicyRef.value && !createReasoningEffortPolicyRef.value.validate() ) { @@ -5672,7 +5673,7 @@ const handleUpdateGroup = async () => { return; } if ( - editForm.platform === "openai" && + supportsReasoningEffortPolicyPlatform(editForm.platform) && editReasoningEffortPolicyRef.value && !editReasoningEffortPolicyRef.value.validate() ) { diff --git a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts index cc5350729b..a3ef7cf276 100644 --- a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts @@ -6,21 +6,28 @@ import { reasoningEffortMappingsToAPI, reasoningEffortMappingsToRows, reasoningEffortOptionsForPlatform, + supportsReasoningEffortPolicyPlatform, validateReasoningEffortMappings, } from "../groupsReasoningEffort"; describe("groupsReasoningEffort", () => { - it("provides fixed OpenAI choices without none", () => { - expect( - reasoningEffortOptionsForPlatform("openai").map((option) => option.value), - ).toEqual([ + it("provides fixed OpenAI choices to OpenAI and Composite groups", () => { + const expected = [ "minimal", "low", "medium", "high", "xhigh", "max", - ]); + ]; + for (const platform of ["openai", "composite"] as const) { + expect( + reasoningEffortOptionsForPlatform(platform).map( + (option) => option.value, + ), + ).toEqual(expected); + expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true); + } for (const platform of [ "anthropic", "gemini", @@ -28,6 +35,7 @@ describe("groupsReasoningEffort", () => { "grok", ] as const) { expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]); + expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false); } }); @@ -48,6 +56,9 @@ describe("groupsReasoningEffort", () => { it("clears values unsupported by OpenAI or used on another platform", () => { expect(normalizeReasoningEffortForPlatform("openai", " MAX ")).toBe("max"); + expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe( + "max", + ); expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe(""); expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe(""); }); diff --git a/frontend/src/views/admin/groupsReasoningEffort.ts b/frontend/src/views/admin/groupsReasoningEffort.ts index 7a3a4504e2..53bf79c3ff 100644 --- a/frontend/src/views/admin/groupsReasoningEffort.ts +++ b/frontend/src/views/admin/groupsReasoningEffort.ts @@ -12,7 +12,15 @@ const openAIReasoningEffortValues = [ const reasoningEffortValuesForPlatform = ( platform: GroupPlatform, ): readonly string[] => - platform === "openai" ? openAIReasoningEffortValues : []; + supportsReasoningEffortPolicyPlatform(platform) + ? openAIReasoningEffortValues + : []; + +export function supportsReasoningEffortPolicyPlatform( + platform: GroupPlatform, +): boolean { + return platform === "openai" || platform === "composite"; +} export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) { return reasoningEffortValuesForPlatform(platform).map((value) => ({