feat(composite): enforce reasoning effort policy

This commit is contained in:
Heatherm Huang
2026-08-01 21:15:44 +08:00
parent b74024c786
commit d60f4e442b
16 changed files with 250 additions and 48 deletions
@@ -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))
}
@@ -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())
@@ -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 {
@@ -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.
@@ -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)
@@ -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)
}
@@ -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
@@ -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
@@ -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"))
@@ -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)
@@ -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
@@ -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',
@@ -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: '删除映射',
+5 -4
View File
@@ -610,7 +610,7 @@
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="createForm.platform === 'openai'"
v-if="supportsReasoningEffortPolicyPlatform(createForm.platform)"
ref="createReasoningEffortPolicyRef"
id-prefix="create-group-reasoning"
:platform="createForm.platform"
@@ -2163,7 +2163,7 @@
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="editForm.platform === 'openai'"
v-if="supportsReasoningEffortPolicyPlatform(editForm.platform)"
ref="editReasoningEffortPolicyRef"
id-prefix="edit-group-reasoning"
:platform="editForm.platform"
@@ -4099,6 +4099,7 @@ import {
normalizeReasoningEffortForPlatform,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
supportsReasoningEffortPolicyPlatform,
type ReasoningEffortMappingRow,
} from "./groupsReasoningEffort";
import {
@@ -5465,7 +5466,7 @@ const handleCreateGroup = async () => {
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()
) {
@@ -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("");
});
@@ -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) => ({