feat(groups): scope OpenAI reasoning effort mappings by model

Allow OpenAI/Composite group mappings to match by exact model name,
prefix, or suffix. Empty type and model still apply to every model.
The admin form groups multiple request-to-forward pairs under one
type+model scope.
This commit is contained in:
刘佳林
2026-08-31 19:21:31 +08:00
parent 52374af940
commit 7c01ec9be8
15 changed files with 996 additions and 103 deletions
+1 -1
View File
@@ -274,7 +274,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 分组不能直接启用。
+14 -2
View File
@@ -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"`
}
@@ -156,7 +156,7 @@ type CreateGroupRequest struct {
RPMLimit int `json:"rpm_limit"`
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// OpenAI/Codex 推理强度精确映射。
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
@@ -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)
})
}
+1 -1
View File
@@ -153,7 +153,7 @@ type Group struct {
RPMLimit int `json:"rpm_limit"`
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
CreatedAt time.Time `json:"created_at"`
@@ -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 {
@@ -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
)
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
@@ -103,8 +105,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)
@@ -127,12 +230,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
}
@@ -194,15 +313,17 @@ func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byt
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings)
}
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) {
@@ -221,15 +342,17 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
group.ReasoningEffortMappings = mappings
}
// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then caps
// known effort levels. 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 caps known
// effort levels. Omitted values remain untouched so upstream defaults stay in
// control.
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping) ([]byte, bool) {
maxRank, hasMax := reasoningEffortRank(maxEffort)
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
return body, false
}
requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
result := body
changed := false
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
@@ -242,7 +365,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 {
@@ -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"}})
@@ -152,6 +215,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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {