diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go
index f5840521c2..38f48b4fdf 100644
--- a/backend/ent/schema/group.go
+++ b/backend/ent/schema/group.go
@@ -284,7 +284,7 @@ func (Group) Fields() []ent.Field {
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
Default([]domain.ReasoningEffortMapping{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
- Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
+ Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
// 的 token 分组可启用,composite 分组不能直接启用。
diff --git a/backend/internal/domain/reasoning_effort.go b/backend/internal/domain/reasoning_effort.go
index 095df21d5e..88f4263fc2 100644
--- a/backend/internal/domain/reasoning_effort.go
+++ b/backend/internal/domain/reasoning_effort.go
@@ -1,8 +1,20 @@
package domain
+const (
+ ReasoningEffortMatchExact = "exact"
+ ReasoningEffortMatchPrefix = "prefix"
+ ReasoningEffortMatchSuffix = "suffix"
+)
+
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
// value to another before the group ceiling is applied.
+//
+// Model and MatchType optionally scope the rewrite to a request model:
+// exact matches the full model id, prefix/suffix match a model-id affix.
+// Empty MatchType and empty Model mean the mapping applies to every model.
type ReasoningEffortMapping struct {
- From string `json:"from"`
- To string `json:"to"`
+ From string `json:"from"`
+ To string `json:"to"`
+ MatchType string `json:"match_type,omitempty"`
+ Model string `json:"model,omitempty"`
}
diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go
index de76931a34..fa6991ee66 100644
--- a/backend/internal/handler/admin/group_handler.go
+++ b/backend/internal/handler/admin/group_handler.go
@@ -160,7 +160,7 @@ type CreateGroupRequest struct {
MaxReasoningEffort string `json:"max_reasoning_effort"`
// 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
- // OpenAI/Codex 推理强度精确映射。
+ // OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
diff --git a/backend/internal/handler/admin/group_handler_reasoning_effort_test.go b/backend/internal/handler/admin/group_handler_reasoning_effort_test.go
index 9e9164ffe4..d4428ddacd 100644
--- a/backend/internal/handler/admin/group_handler_reasoning_effort_test.go
+++ b/backend/internal/handler/admin/group_handler_reasoning_effort_test.go
@@ -29,4 +29,15 @@ func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
})
+
+ t.Run("accepts model scoped mappings", func(t *testing.T) {
+ var req UpdateGroupRequest
+ require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"low","match_type":"prefix","model":"gpt"}]}`), &req))
+ require.NotNil(t, req.ReasoningEffortMappings)
+ require.Len(t, *req.ReasoningEffortMappings, 1)
+ require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
+ require.Equal(t, "low", (*req.ReasoningEffortMappings)[0].To)
+ require.Equal(t, "prefix", (*req.ReasoningEffortMappings)[0].MatchType)
+ require.Equal(t, "gpt", (*req.ReasoningEffortMappings)[0].Model)
+ })
}
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index 90f565e434..7fb8373810 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -155,7 +155,7 @@ type Group struct {
MaxReasoningEffort string `json:"max_reasoning_effort"`
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
- // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
+ // ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
CreatedAt time.Time `json:"created_at"`
diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go
index aa1f08f560..e14c2a0b39 100644
--- a/backend/internal/service/admin_service_group_test.go
+++ b/backend/internal/service/admin_service_group_test.go
@@ -886,6 +886,20 @@ func TestAdminService_UpdateGroup_ReasoningEffortMappingsTriState(t *testing.T)
}(),
want: []ReasoningEffortMapping{{From: "xhigh", To: "high"}},
},
+ {
+ name: "model scoped mappings are canonicalized independently",
+ input: func() *UpdateGroupInput {
+ replacement := []ReasoningEffortMapping{
+ {From: " MAX ", To: " low ", MatchType: "PREFIX", Model: " gpt "},
+ {From: "max", To: "medium", Model: "gpt-5.4"},
+ }
+ return &UpdateGroupInput{ReasoningEffortMappings: &replacement}
+ }(),
+ want: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: "prefix", Model: "gpt"},
+ {From: "max", To: "medium", MatchType: "exact", Model: "gpt-5.4"},
+ },
+ },
}
for _, tt := range tests {
diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go
index 97ccb28517..caa1291120 100644
--- a/backend/internal/service/openai_reasoning_effort_policy.go
+++ b/backend/internal/service/openai_reasoning_effort_policy.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
+ "github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -12,6 +13,7 @@ import (
const (
maxReasoningEffortMappings = 64
maxReasoningEffortValueLen = 64
+ maxReasoningEffortModelLen = 200
// ReasoningEffortOverLimitDowngrade rewrites values above the ceiling.
ReasoningEffortOverLimitDowngrade = "downgrade"
@@ -170,8 +172,109 @@ func reasoningEffortRank(raw string) (int, bool) {
}
}
+func normalizeReasoningEffortMatchType(matchType, model string) (string, error) {
+ model = strings.TrimSpace(model)
+ matchType = strings.ToLower(strings.TrimSpace(matchType))
+ if model == "" {
+ return "", nil
+ }
+ switch matchType {
+ case "", domain.ReasoningEffortMatchExact:
+ return domain.ReasoningEffortMatchExact, nil
+ case domain.ReasoningEffortMatchPrefix:
+ return domain.ReasoningEffortMatchPrefix, nil
+ case domain.ReasoningEffortMatchSuffix:
+ return domain.ReasoningEffortMatchSuffix, nil
+ default:
+ return "", fmt.Errorf("invalid match_type %q", matchType)
+ }
+}
+
+func reasoningEffortMappingDuplicateKey(from, matchType, model string) string {
+ return from + "\x00" + matchType + "\x00" + strings.ToLower(strings.TrimSpace(model))
+}
+
+func requestModelMatchesReasoningEffortMapping(requestModel, matchType, mappingModel string) bool {
+ scope := strings.ToLower(strings.TrimSpace(mappingModel))
+ req := strings.ToLower(strings.TrimSpace(requestModel))
+ switch matchType {
+ case "":
+ return true
+ case domain.ReasoningEffortMatchExact:
+ return scope != "" && scope == req
+ case domain.ReasoningEffortMatchPrefix:
+ return scope != "" && req != "" && strings.HasPrefix(req, scope)
+ case domain.ReasoningEffortMatchSuffix:
+ return scope != "" && req != "" && strings.HasSuffix(req, scope)
+ default:
+ return false
+ }
+}
+
+func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, requestModel string) (ReasoningEffortMapping, bool) {
+ type candidate struct {
+ mapping ReasoningEffortMapping
+ matchStrength int
+ patternLen int
+ index int
+ }
+ candidates := make([]candidate, 0, len(mappings))
+ for i, mapping := range mappings {
+ if NormalizeMaxReasoningEffort(mapping.From) != from {
+ continue
+ }
+ model := strings.TrimSpace(mapping.Model)
+ matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
+ if err != nil {
+ continue
+ }
+ if !requestModelMatchesReasoningEffortMapping(requestModel, matchType, model) {
+ continue
+ }
+ strength := 1
+ patternLen := 0
+ switch matchType {
+ case domain.ReasoningEffortMatchExact:
+ strength = 3
+ case domain.ReasoningEffortMatchPrefix, domain.ReasoningEffortMatchSuffix:
+ strength = 2
+ patternLen = len(strings.ToLower(model))
+ }
+ candidates = append(candidates, candidate{
+ mapping: mapping,
+ matchStrength: strength,
+ patternLen: patternLen,
+ index: i,
+ })
+ }
+ if len(candidates) == 0 {
+ return ReasoningEffortMapping{}, false
+ }
+ best := candidates[0]
+ for _, item := range candidates[1:] {
+ if item.matchStrength != best.matchStrength {
+ if item.matchStrength > best.matchStrength {
+ best = item
+ }
+ continue
+ }
+ if item.patternLen != best.patternLen {
+ if item.patternLen > best.patternLen {
+ best = item
+ }
+ continue
+ }
+ if item.index < best.index {
+ best = item
+ }
+ }
+ return best.mapping, true
+}
+
// NormalizeReasoningEffortMappings validates group mapping rules against the
-// fixed effort values supported by OpenAI routes.
+// fixed effort values supported by OpenAI routes. Optional model scopes are
+// canonicalized to exact/prefix/suffix match; empty type and model keep the
+// global all-models rule.
func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) {
if len(raw) > maxReasoningEffortMappings {
return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings)
@@ -194,12 +297,28 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp
if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
}
- key := from
+ model := strings.TrimSpace(mapping.Model)
+ if len(model) > maxReasoningEffortModelLen {
+ return nil, fmt.Errorf("reasoning effort mapping %d model cannot exceed %d characters", i+1, maxReasoningEffortModelLen)
+ }
+ matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
+ if err != nil {
+ return nil, fmt.Errorf("reasoning effort mapping %d: %w", i+1, err)
+ }
+ key := reasoningEffortMappingDuplicateKey(from, matchType, model)
if _, exists := seen[key]; exists {
- return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from)
+ if model == "" {
+ return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from)
+ }
+ return nil, fmt.Errorf("duplicate reasoning effort mapping source %q for %s model %q", from, matchType, strings.ToLower(model))
}
seen[key] = struct{}{}
- normalized = append(normalized, ReasoningEffortMapping{From: from, To: to})
+ normalized = append(normalized, ReasoningEffortMapping{
+ From: from,
+ To: to,
+ MatchType: matchType,
+ Model: model,
+ })
}
return normalized, nil
}
@@ -262,15 +381,17 @@ func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byt
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit)
}
-func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) {
+func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) {
value := strings.TrimSpace(raw)
canonical := NormalizeMaxReasoningEffort(value)
- for _, mapping := range mappings {
- if canonical != "" && canonical == NormalizeMaxReasoningEffort(mapping.From) {
- return strings.TrimSpace(mapping.To), true
- }
+ if canonical == "" {
+ return value, false
}
- return value, false
+ mapping, ok := selectReasoningEffortMapping(mappings, canonical, requestModel)
+ if !ok {
+ return value, false
+ }
+ return strings.TrimSpace(mapping.To), true
}
func sanitizeGroupReasoningEffortPolicy(group *Group) {
@@ -294,10 +415,11 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
group.ReasoningEffortMappings = mappings
}
-// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then either
-// caps known effort levels or rejects the request when the group is configured
-// to deny values above the ceiling. Omitted values remain untouched so
-// upstream defaults stay in control.
+// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to
+// the request model by exact name, prefix, or suffix) and then either caps
+// known effort levels or rejects the request when the group is configured to
+// deny values above the ceiling. Omitted values remain untouched so upstream
+// defaults stay in control.
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
maxRank, hasMax := reasoningEffortRank(maxEffort)
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
@@ -306,6 +428,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []
deny := hasMax && NormalizeMaxReasoningEffortOverLimit(overLimit) == ReasoningEffortOverLimitDeny
canonicalMax := NormalizeMaxReasoningEffort(maxEffort)
+ requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
result := body
changed := false
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
@@ -318,7 +441,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []
continue
}
- effective, _ := mapReasoningEffort(original, mappings)
+ effective, _ := mapReasoningEffort(original, mappings, requestModel)
if currentRank, recognized := reasoningEffortRank(effective); recognized {
effective = NormalizeMaxReasoningEffort(effective)
if hasMax && currentRank > maxRank {
diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go
index e504471f1e..fae609d7c1 100644
--- a/backend/internal/service/openai_reasoning_effort_policy_test.go
+++ b/backend/internal/service/openai_reasoning_effort_policy_test.go
@@ -4,6 +4,7 @@ import (
"context"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
@@ -83,6 +84,68 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) {
require.ErrorContains(t, err, "duplicate")
})
+ t.Run("allows same source across different model scopes", func(t *testing.T) {
+ got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: "prefix", Model: " gpt "},
+ {From: "max", To: "medium", MatchType: "exact", Model: "GPT-5.4"},
+ {From: "max", To: "high"},
+ })
+ require.NoError(t, err)
+ require.Equal(t, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
+ {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "GPT-5.4"},
+ {From: "max", To: "high"},
+ }, got)
+ })
+
+ t.Run("defaults missing match type to exact when model is set", func(t *testing.T) {
+ got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", Model: "gpt-5.4"},
+ })
+ require.NoError(t, err)
+ require.Equal(t, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"},
+ }, got)
+ })
+
+ t.Run("empty type and model collapse to a global mapping", func(t *testing.T) {
+ got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: "prefix"},
+ {From: "high", To: "low", MatchType: "suffix"},
+ })
+ require.NoError(t, err)
+ require.Equal(t, []ReasoningEffortMapping{
+ {From: "max", To: "low"},
+ {From: "high", To: "low"},
+ }, got)
+ })
+
+ t.Run("canonicalizes suffix match", func(t *testing.T) {
+ got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: " SUFFIX ", Model: " mini "},
+ })
+ require.NoError(t, err)
+ require.Equal(t, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
+ }, got)
+ })
+
+ t.Run("rejects invalid match type", func(t *testing.T) {
+ _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: "wildcard", Model: "gpt"},
+ })
+ require.ErrorContains(t, err, "invalid match_type")
+ })
+
+ t.Run("rejects duplicate source within the same model scope", func(t *testing.T) {
+ _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: "prefix", Model: "gpt"},
+ {From: "MAX", To: "high", MatchType: "PREFIX", Model: " GPT "},
+ })
+ require.ErrorContains(t, err, "duplicate")
+ require.ErrorContains(t, err, "gpt")
+ })
+
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"}})
@@ -191,6 +254,131 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
{name: "does not chain mappings", body: `{"reasoning_effort":"max"}`, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}, {From: "xhigh", To: "low"}}, path: "reasoning_effort", want: "xhigh", changed: true},
{name: "keeps unknown without mapping", body: `{"reasoning_effort":"future"}`, max: "low", path: "reasoning_effort", want: "future", changed: false},
{name: "keeps non string value", body: `{"reasoning_effort":{"level":"high"}}`, max: "low", path: "reasoning_effort.level", want: "high", changed: false},
+ {
+ name: "prefix mapping applies to matching model",
+ body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}},
+ path: "reasoning_effort",
+ want: "low",
+ changed: true,
+ },
+ {
+ name: "prefix mapping skips non matching model",
+ body: `{"model":"o3","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}},
+ path: "reasoning_effort",
+ want: "max",
+ changed: false,
+ },
+ {
+ name: "exact mapping beats prefix",
+ body: `{"model":"gpt-5.4","reasoning":{"effort":"max"}}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
+ {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"},
+ },
+ path: "reasoning.effort",
+ want: "medium",
+ changed: true,
+ },
+ {
+ name: "longer prefix beats shorter prefix",
+ body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
+ {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5"},
+ },
+ path: "reasoning_effort",
+ want: "high",
+ changed: true,
+ },
+ {
+ name: "falls back to global mapping when no model scope hits",
+ body: `{"model":"o3","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
+ {From: "max", To: "medium"},
+ },
+ path: "reasoning_effort",
+ want: "medium",
+ changed: true,
+ },
+ {
+ name: "model scoped mapping still respects ceiling",
+ body: `{"model":"gpt-5.4","reasoning":{"effort":"max"}}`,
+ max: "medium",
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "xhigh", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
+ },
+ path: "reasoning.effort",
+ want: "medium",
+ changed: true,
+ },
+ {
+ name: "model match is case insensitive",
+ body: `{"model":"GPT-5.4","reasoning_effort":"MAX"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"}},
+ path: "reasoning_effort",
+ want: "low",
+ changed: true,
+ },
+ {
+ name: "suffix mapping applies to matching model",
+ body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}},
+ path: "reasoning_effort",
+ want: "low",
+ changed: true,
+ },
+ {
+ name: "suffix mapping skips non matching model",
+ body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}},
+ path: "reasoning_effort",
+ want: "max",
+ changed: false,
+ },
+ {
+ name: "exact mapping beats suffix",
+ body: `{"model":"gpt-5.4-mini","reasoning":{"effort":"max"}}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
+ {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4-mini"},
+ },
+ path: "reasoning.effort",
+ want: "medium",
+ changed: true,
+ },
+ {
+ name: "longer suffix beats shorter suffix",
+ body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "i"},
+ {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
+ },
+ path: "reasoning_effort",
+ want: "high",
+ changed: true,
+ },
+ {
+ name: "longer affix beats the other affix",
+ body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{
+ {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
+ {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.4"},
+ },
+ path: "reasoning_effort",
+ want: "high",
+ changed: true,
+ },
+ {
+ name: "empty type and model apply to every model",
+ body: `{"model":"o3","reasoning_effort":"max"}`,
+ mappings: []ReasoningEffortMapping{{From: "max", To: "low"}},
+ path: "reasoning_effort",
+ want: "low",
+ changed: true,
+ },
{name: "deny rejects over ceiling", body: `{"reasoning_effort":"high"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high", deny: true},
{name: "deny keeps value at ceiling", body: `{"reasoning_effort":"low"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "low"},
{name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true},
diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue
index 99ac1fdc3c..4b80d73fca 100644
--- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue
+++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue
@@ -35,13 +35,18 @@
-
+
+
+
+ {{ t("admin.groups.form.reasoningEffortMappingsHint") }}
+
+