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) => ({