mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #6425 from DuskLin/feat/openai-reasoning-effort-model-scope
feat(group): OpenAI 推理强度映射支持按模型名/前缀/后缀匹配
This commit is contained in:
@@ -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 分组不能直接启用。
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -35,13 +35,18 @@
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<label class="input-label mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappings") }}
|
||||
</label>
|
||||
<div>
|
||||
<label class="input-label mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappings") }}
|
||||
</label>
|
||||
<p class="input-hint mb-0">
|
||||
{{ t("admin.groups.form.reasoningEffortMappingsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
|
||||
@click="addMapping"
|
||||
class="inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:bg-primary-900/20 dark:hover:text-primary-300"
|
||||
@click="addGroup"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t("admin.groups.form.addReasoningEffortMapping") }}
|
||||
@@ -50,77 +55,151 @@
|
||||
|
||||
<div v-if="mappings.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="row in mappings"
|
||||
:key="row.id"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50/40 p-3 dark:border-dark-600 dark:bg-dark-800/40"
|
||||
v-for="group in mappings"
|
||||
:key="group.id"
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-gray-50/40 p-3 dark:border-dark-600 dark:bg-dark-800/40"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div
|
||||
class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)_2.75rem]"
|
||||
>
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-from`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortFrom") }}
|
||||
<label :for="`${idPrefix}-${group.id}-match-type`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortMatchType") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${row.id}-from`"
|
||||
:model-value="row.from"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortFromPlaceholder')"
|
||||
:error="showValidation && !!validationErrors[row.id]?.from"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortFrom')"
|
||||
:aria-describedby="showValidation && validationErrors[row.id]?.from ? `${idPrefix}-${row.id}-from-error` : undefined"
|
||||
:id="`${idPrefix}-${group.id}-match-type`"
|
||||
:model-value="group.match_type"
|
||||
:options="matchTypeOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortMatchTypePlaceholder')"
|
||||
:error="showValidation && !!groupErrors(group.id).match_type"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortMatchType')"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMapping(row.id, 'from', $event)"
|
||||
@update:model-value="updateGroup(group.id, 'match_type', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.from"
|
||||
:id="`${idPrefix}-${row.id}-from-error`"
|
||||
v-if="showValidation && groupErrors(group.id).match_type"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.id]?.from) }}
|
||||
{{ mappingErrorText(groupErrors(group.id).match_type) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden pt-8 text-gray-400 md:block dark:text-dark-400">
|
||||
<div class="hidden md:block" aria-hidden="true" />
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${group.id}-model`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortModel") }}
|
||||
</label>
|
||||
<input
|
||||
:id="`${idPrefix}-${group.id}-model`"
|
||||
:value="group.model"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortModelPlaceholder')"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortModel')"
|
||||
@input="onModelInput(group.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center self-end rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
@click="removeGroup(group.id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showValidation && groupErrors(group.id).duplicateScope"
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(groupErrors(group.id).duplicateScope) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="pair in group.pairs"
|
||||
:key="pair.id"
|
||||
class="grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_1.25rem_minmax(0,1fr)_2.75rem]"
|
||||
>
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${pair.id}-from`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortFrom") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${pair.id}-from`"
|
||||
:model-value="pair.from"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortFromPlaceholder')"
|
||||
:error="showValidation && !!pairErrors(pair.id).from"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortFrom')"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updatePair(group.id, pair.id, 'from', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && pairErrors(pair.id).from"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(pairErrors(pair.id).from) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden h-11 items-center justify-center self-end text-gray-400 md:flex dark:text-dark-400">
|
||||
<Icon name="arrowRight" size="sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`${idPrefix}-${row.id}-to`" class="input-label">
|
||||
<label :for="`${idPrefix}-${pair.id}-to`" class="input-label">
|
||||
{{ t("admin.groups.form.reasoningEffortTo") }}
|
||||
</label>
|
||||
<Select
|
||||
:id="`${idPrefix}-${row.id}-to`"
|
||||
:model-value="row.to"
|
||||
:id="`${idPrefix}-${pair.id}-to`"
|
||||
:model-value="pair.to"
|
||||
:options="reasoningEffortOptions"
|
||||
:placeholder="t('admin.groups.form.reasoningEffortToPlaceholder')"
|
||||
:error="showValidation && !!validationErrors[row.id]?.to"
|
||||
:error="showValidation && !!pairErrors(pair.id).to"
|
||||
:aria-label="t('admin.groups.form.reasoningEffortTo')"
|
||||
:aria-describedby="showValidation && validationErrors[row.id]?.to ? `${idPrefix}-${row.id}-to-error` : undefined"
|
||||
:searchable="false"
|
||||
clearable
|
||||
@update:model-value="updateMapping(row.id, 'to', $event)"
|
||||
@update:model-value="updatePair(group.id, pair.id, 'to', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="showValidation && validationErrors[row.id]?.to"
|
||||
:id="`${idPrefix}-${row.id}-to-error`"
|
||||
v-if="showValidation && pairErrors(pair.id).to"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrorText(validationErrors[row.id]?.to) }}
|
||||
{{ mappingErrorText(pairErrors(pair.id).to) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 md:mt-6 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortMapping')"
|
||||
@click="removeMapping(row.id)"
|
||||
class="flex h-11 w-11 items-center justify-center self-end rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.groups.form.removeReasoningEffortPair')"
|
||||
:aria-label="t('admin.groups.form.removeReasoningEffortPair')"
|
||||
@click="removePair(group.id, pair.id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-9 items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="addPair(group.id)"
|
||||
>
|
||||
<Icon name="plus" size="sm" />
|
||||
{{ t("admin.groups.form.addReasoningEffortPair") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +213,9 @@ import type { GroupPlatform } from "@/types";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import Select from "@/components/common/Select.vue";
|
||||
import {
|
||||
createReasoningEffortMappingPair,
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortMatchType,
|
||||
reasoningEffortOptionsForPlatform,
|
||||
reasoningEffortOverLimitDeny,
|
||||
reasoningEffortOverLimitDowngrade,
|
||||
@@ -162,6 +243,20 @@ const showValidation = ref(false);
|
||||
const reasoningEffortOptions = computed(() =>
|
||||
reasoningEffortOptionsForPlatform(props.platform),
|
||||
);
|
||||
const matchTypeOptions = computed(() => [
|
||||
{
|
||||
value: "exact",
|
||||
label: t("admin.groups.form.reasoningEffortMatchExact"),
|
||||
},
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("admin.groups.form.reasoningEffortMatchPrefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("admin.groups.form.reasoningEffortMatchSuffix"),
|
||||
},
|
||||
]);
|
||||
const overLimitOptions = computed(() => [
|
||||
{
|
||||
value: reasoningEffortOverLimitDowngrade,
|
||||
@@ -179,6 +274,9 @@ const validationErrors = computed(() =>
|
||||
const asString = (value: string | number | boolean | null): string =>
|
||||
value == null ? "" : String(value);
|
||||
|
||||
const groupErrors = (id: string) => validationErrors.value[id] ?? {};
|
||||
const pairErrors = (id: string) => validationErrors.value[id] ?? {};
|
||||
|
||||
const updateMaxEffort = (value: string | number | boolean | null) => {
|
||||
emit("update:maxEffort", asString(value));
|
||||
};
|
||||
@@ -190,30 +288,82 @@ const updateOverLimit = (value: string | number | boolean | null) => {
|
||||
);
|
||||
};
|
||||
|
||||
const updateMapping = (
|
||||
const updateGroup = (
|
||||
id: string,
|
||||
field: "match_type" | "model",
|
||||
value: string | number | boolean | null,
|
||||
) => {
|
||||
const nextValue =
|
||||
field === "match_type"
|
||||
? normalizeReasoningEffortMatchType(asString(value))
|
||||
: asString(value);
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((group) =>
|
||||
group.id === id ? { ...group, [field]: nextValue } : group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onModelInput = (id: string, event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
updateGroup(id, "model", target?.value ?? "");
|
||||
};
|
||||
|
||||
const updatePair = (
|
||||
groupId: string,
|
||||
pairId: string,
|
||||
field: "from" | "to",
|
||||
value: string | number | boolean | null,
|
||||
) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((row) =>
|
||||
row.id === id ? { ...row, [field]: asString(value) } : row,
|
||||
props.mappings.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
pairs: group.pairs.map((pair) =>
|
||||
pair.id === pairId ? { ...pair, [field]: asString(value) } : pair,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
const addGroup = () => {
|
||||
emit("update:mappings", [
|
||||
...props.mappings,
|
||||
createReasoningEffortMappingRow(),
|
||||
]);
|
||||
};
|
||||
|
||||
const removeMapping = (id: string) => {
|
||||
const removeGroup = (id: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.filter((row) => row.id !== id),
|
||||
props.mappings.filter((group) => group.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const addPair = (groupId: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.map((group) =>
|
||||
group.id === groupId
|
||||
? { ...group, pairs: [...group.pairs, createReasoningEffortMappingPair()] }
|
||||
: group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removePair = (groupId: string, pairId: string) => {
|
||||
emit(
|
||||
"update:mappings",
|
||||
props.mappings.flatMap((group) => {
|
||||
if (group.id !== groupId) return [group];
|
||||
const pairs = group.pairs.filter((pair) => pair.id !== pairId);
|
||||
return pairs.length > 0 ? [{ ...group, pairs }] : [];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ReasoningEffortPolicyFields from "../ReasoningEffortPolicyFields.vue";
|
||||
import { createReasoningEffortMappingRow } from "@/views/admin/groupsReasoningEffort";
|
||||
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n");
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ReasoningEffortPolicyFields", () => {
|
||||
it("renders model scope fields for each mapping", () => {
|
||||
const mapping = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
const wrapper = mount(ReasoningEffortPolicyFields, {
|
||||
props: {
|
||||
idPrefix: "create-group-reasoning",
|
||||
platform: "openai",
|
||||
maxEffort: "",
|
||||
overLimit: "downgrade",
|
||||
mappings: [mapping],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: { props: ["name"], template: '<i :data-icon="name" />' },
|
||||
Select: {
|
||||
props: ["id", "modelValue", "placeholder"],
|
||||
template:
|
||||
'<input :id="id" :value="modelValue ?? \'\'" :placeholder="placeholder" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortMatchType");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortModel");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortFrom");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.reasoningEffortTo");
|
||||
expect(wrapper.text()).toContain("admin.groups.form.addReasoningEffortPair");
|
||||
|
||||
const modelInput = wrapper.get(
|
||||
`#create-group-reasoning-${mapping.id}-model`,
|
||||
);
|
||||
expect(modelInput.element).toMatchObject({
|
||||
value: "gpt",
|
||||
placeholder: "admin.groups.form.reasoningEffortModelPlaceholder",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a new mapping group with one empty pair", async () => {
|
||||
const wrapper = mount(ReasoningEffortPolicyFields, {
|
||||
props: {
|
||||
idPrefix: "edit-group-reasoning",
|
||||
platform: "openai",
|
||||
maxEffort: "",
|
||||
overLimit: "downgrade",
|
||||
mappings: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: { props: ["name"], template: '<i :data-icon="name" />' },
|
||||
Select: { template: "<div />" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get("button").trigger("click");
|
||||
|
||||
const emitted = wrapper.emitted("update:mappings");
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted?.[0]?.[0]).toEqual([
|
||||
expect.objectContaining({
|
||||
match_type: "",
|
||||
model: "",
|
||||
pairs: [
|
||||
expect.objectContaining({
|
||||
from: "",
|
||||
to: "",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -865,8 +865,18 @@ export default {
|
||||
maxReasoningEffortOverLimitDeny: 'Deny access',
|
||||
maxReasoningEffortOverLimitHint: 'Applies after a ceiling is set. Downgrade rewrites values above the ceiling to the ceiling. Deny rejects the request.',
|
||||
reasoningEffortMappings: 'Reasoning effort mappings',
|
||||
reasoningEffortMappingsHint: 'Type and model can both be left empty to match every model. One type and model can hold multiple request mappings, for example prefix gpt mapping both high and xhigh to medium. Exact matches beat affixes, and longer affixes beat shorter ones.',
|
||||
addReasoningEffortMapping: 'Add mapping',
|
||||
addReasoningEffortPair: 'Add request value',
|
||||
removeReasoningEffortMapping: 'Remove mapping',
|
||||
removeReasoningEffortPair: 'Remove request value',
|
||||
reasoningEffortMatchType: 'Type',
|
||||
reasoningEffortModel: 'Model',
|
||||
reasoningEffortMatchExact: 'Exact',
|
||||
reasoningEffortMatchPrefix: 'Prefix',
|
||||
reasoningEffortMatchSuffix: 'Suffix',
|
||||
reasoningEffortMatchTypePlaceholder: 'All models',
|
||||
reasoningEffortModelPlaceholder: 'Empty = all / gpt / gpt-5.4',
|
||||
reasoningEffortFrom: 'Request value',
|
||||
reasoningEffortTo: 'Forwarded value',
|
||||
reasoningEffortFromPlaceholder: 'Select A',
|
||||
@@ -875,7 +885,9 @@ export default {
|
||||
toRequired: 'Select forwarded value B',
|
||||
unsupportedFrom: 'Request value is not supported by this platform',
|
||||
unsupportedTo: 'Forwarded value is not supported by this platform',
|
||||
duplicateFrom: 'Request value A must be unique',
|
||||
unsupportedMatchType: 'Match type must be exact, prefix, or suffix',
|
||||
duplicateFrom: 'Request value A must be unique within the same model scope',
|
||||
duplicateScope: 'Type and model combination must be unique',
|
||||
exclusiveLabel: 'Exclusive Group',
|
||||
exclusiveHint: 'Exclusive group, can be manually assigned to users',
|
||||
platformLabel: 'Platform Restriction',
|
||||
|
||||
@@ -847,8 +847,18 @@ export default {
|
||||
maxReasoningEffortOverLimitDeny: '拒绝访问',
|
||||
maxReasoningEffortOverLimitHint: '设置上限后生效。自动降档会将超过上限的请求改写为上限值后转发;拒绝访问则直接返回错误。',
|
||||
reasoningEffortMappings: '推理强度映射',
|
||||
reasoningEffortMappingsHint: '类型和模型均可留空,表示匹配全部模型。同一类型和模型下可添加多条请求值映射,例如前缀 gpt 同时将 high、xhigh 转到 medium。精确优先于前后缀,更长前后缀优先。',
|
||||
addReasoningEffortMapping: '添加映射',
|
||||
addReasoningEffortPair: '添加请求值',
|
||||
removeReasoningEffortMapping: '删除映射',
|
||||
removeReasoningEffortPair: '删除请求值',
|
||||
reasoningEffortMatchType: '类型',
|
||||
reasoningEffortModel: '模型',
|
||||
reasoningEffortMatchExact: '精确',
|
||||
reasoningEffortMatchPrefix: '前缀',
|
||||
reasoningEffortMatchSuffix: '后缀',
|
||||
reasoningEffortMatchTypePlaceholder: '全匹配',
|
||||
reasoningEffortModelPlaceholder: '留空则全部 / gpt / gpt-5.4',
|
||||
reasoningEffortFrom: '请求值',
|
||||
reasoningEffortTo: '转发值',
|
||||
reasoningEffortFromPlaceholder: '请选择 A',
|
||||
@@ -857,7 +867,9 @@ export default {
|
||||
toRequired: '请选择转发值 B',
|
||||
unsupportedFrom: '请求值不受当前平台支持',
|
||||
unsupportedTo: '转发值不受当前平台支持',
|
||||
duplicateFrom: '请求值 A 不能重复',
|
||||
unsupportedMatchType: '匹配类型仅支持精确、前缀或后缀',
|
||||
duplicateFrom: '同一模型范围内请求值不能重复',
|
||||
duplicateScope: '类型和模型组合不能重复',
|
||||
exclusiveLabel: '专属分组',
|
||||
exclusiveHint: '专属分组,可以手动指定给用户',
|
||||
platformLabel: '平台限制',
|
||||
|
||||
@@ -544,9 +544,13 @@ export interface OpenAIMessagesDispatchModelConfig {
|
||||
exact_model_mappings?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ReasoningEffortMatchType = 'exact' | 'prefix' | 'suffix'
|
||||
|
||||
export interface ReasoningEffortMapping {
|
||||
from: string
|
||||
to: string
|
||||
match_type?: ReasoningEffortMatchType
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createReasoningEffortMappingPair,
|
||||
createReasoningEffortMappingRow,
|
||||
normalizeReasoningEffortForPlatform,
|
||||
normalizeReasoningEffortMatchType,
|
||||
normalizeReasoningEffortOverLimit,
|
||||
reasoningEffortMappingsToAPI,
|
||||
reasoningEffortMappingsToRows,
|
||||
@@ -57,6 +59,71 @@ describe("groupsReasoningEffort", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates model scoped mappings", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[
|
||||
{
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: " gpt ",
|
||||
},
|
||||
{
|
||||
from: "max",
|
||||
to: "medium",
|
||||
match_type: "exact",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
"openai",
|
||||
);
|
||||
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "max", to: "low", match_type: "prefix", model: "gpt" },
|
||||
{ from: "max", to: "medium", match_type: "exact", model: "gpt-5.4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups multiple request mappings under the same type and model", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "GPT" },
|
||||
],
|
||||
"openai",
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].match_type).toBe("prefix");
|
||||
expect(rows[0].model).toBe("gpt");
|
||||
expect(rows[0].pairs.map((pair) => pair.from)).toEqual(["high", "xhigh"]);
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits empty model scopes from the API payload", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "max", to: "low" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates suffix mappings", () => {
|
||||
const rows = reasoningEffortMappingsToRows(
|
||||
[{ from: "max", to: "low", match_type: "suffix", model: " mini " }],
|
||||
"openai",
|
||||
);
|
||||
expect(reasoningEffortMappingsToAPI(rows)).toEqual([
|
||||
{ from: "max", to: "low", match_type: "suffix", model: "mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears values unsupported by OpenAI or used on another platform", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("openai", " MAX ")).toBe("max");
|
||||
expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe(
|
||||
@@ -66,13 +133,23 @@ describe("groupsReasoningEffort", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes match type to exact, prefix, suffix, or empty", () => {
|
||||
expect(normalizeReasoningEffortMatchType("PREFIX")).toBe("prefix");
|
||||
expect(normalizeReasoningEffortMatchType("suffix")).toBe("suffix");
|
||||
expect(normalizeReasoningEffortMatchType("exact")).toBe("exact");
|
||||
expect(normalizeReasoningEffortMatchType("")).toBe("");
|
||||
expect(normalizeReasoningEffortMatchType("wildcard")).toBe("");
|
||||
});
|
||||
|
||||
it("requires both sides of every mapping", () => {
|
||||
const first = createReasoningEffortMappingRow({ to: "low" });
|
||||
const second = createReasoningEffortMappingRow({ from: "max" });
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { from: "fromRequired" },
|
||||
[second.id]: { to: "toRequired" },
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "fromRequired" },
|
||||
[second.pairs[0].id]: { to: "toRequired" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,15 +158,97 @@ describe("groupsReasoningEffort", () => {
|
||||
const second = createReasoningEffortMappingRow({ from: " max ", to: "high" });
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { from: "duplicateFrom" },
|
||||
[second.id]: { from: "duplicateFrom" },
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "duplicateFrom" },
|
||||
[second.pairs[0].id]: { from: "duplicateFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the same source across different model scopes", () => {
|
||||
const prefix = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
const exact = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "medium",
|
||||
match_type: "exact",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
const global = createReasoningEffortMappingRow({ from: "max", to: "high" });
|
||||
|
||||
expect(validateReasoningEffortMappings([prefix, exact, global])).toEqual({});
|
||||
});
|
||||
|
||||
it("allows multiple request values in one model scope", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "high",
|
||||
to: "medium",
|
||||
match_type: "prefix",
|
||||
model: "gpt",
|
||||
});
|
||||
row.pairs.push(
|
||||
createReasoningEffortMappingPair({ from: "xhigh", to: "medium" }),
|
||||
);
|
||||
|
||||
expect(validateReasoningEffortMappings([row])).toEqual({});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "high", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
{ from: "xhigh", to: "medium", match_type: "prefix", model: "gpt" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate source values within the same model scope", () => {
|
||||
const first = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
model: "GPT",
|
||||
});
|
||||
const second = createReasoningEffortMappingRow({
|
||||
from: "MAX",
|
||||
to: "high",
|
||||
match_type: "prefix",
|
||||
model: " gpt ",
|
||||
});
|
||||
|
||||
expect(validateReasoningEffortMappings([first, second])).toEqual({
|
||||
[first.id]: { duplicateScope: "duplicateScope" },
|
||||
[second.id]: { duplicateScope: "duplicateScope" },
|
||||
[first.pairs[0].id]: { from: "duplicateFrom" },
|
||||
[second.pairs[0].id]: { from: "duplicateFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows empty type and model as a global mapping", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
});
|
||||
expect(row.match_type).toBe("");
|
||||
expect(row.model).toBe("");
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({});
|
||||
});
|
||||
|
||||
it("treats prefix without a model as a global mapping", () => {
|
||||
const row = createReasoningEffortMappingRow({
|
||||
from: "max",
|
||||
to: "low",
|
||||
match_type: "prefix",
|
||||
});
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({});
|
||||
expect(reasoningEffortMappingsToAPI([row])).toEqual([
|
||||
{ from: "max", to: "low" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects custom mappings", () => {
|
||||
const row = createReasoningEffortMappingRow({ from: "ultra", to: "high" });
|
||||
expect(validateReasoningEffortMappings([row], "openai")).toEqual({
|
||||
[row.id]: { from: "unsupportedFrom" },
|
||||
[row.pairs[0].id]: { from: "unsupportedFrom" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { GroupPlatform, ReasoningEffortMapping } from "@/types";
|
||||
import type {
|
||||
GroupPlatform,
|
||||
ReasoningEffortMapping,
|
||||
ReasoningEffortMatchType,
|
||||
} from "@/types";
|
||||
|
||||
const openAIReasoningEffortValues = [
|
||||
"minimal",
|
||||
@@ -9,6 +13,12 @@ const openAIReasoningEffortValues = [
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
|
||||
"exact",
|
||||
"prefix",
|
||||
"suffix",
|
||||
];
|
||||
|
||||
export const reasoningEffortOverLimitDowngrade = "downgrade";
|
||||
export const reasoningEffortOverLimitDeny = "deny";
|
||||
|
||||
@@ -44,6 +54,15 @@ export function normalizeReasoningEffortForPlatform(
|
||||
: "";
|
||||
}
|
||||
|
||||
export function normalizeReasoningEffortMatchType(
|
||||
value: string | null | undefined,
|
||||
): ReasoningEffortMatchType | "" {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return reasoningEffortMatchTypes.some((allowed) => allowed === normalized)
|
||||
? (normalized as ReasoningEffortMatchType)
|
||||
: "";
|
||||
}
|
||||
|
||||
export function normalizeReasoningEffortOverLimit(
|
||||
value: string | null | undefined,
|
||||
): string {
|
||||
@@ -52,55 +71,135 @@ export function normalizeReasoningEffortOverLimit(
|
||||
: reasoningEffortOverLimitDowngrade;
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow extends ReasoningEffortMapping {
|
||||
export interface ReasoningEffortMappingPair {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ReasoningEffortMappingRow {
|
||||
id: string;
|
||||
match_type: ReasoningEffortMatchType | "";
|
||||
model: string;
|
||||
pairs: ReasoningEffortMappingPair[];
|
||||
}
|
||||
|
||||
export type ReasoningEffortMappingErrorCode =
|
||||
| "fromRequired"
|
||||
| "toRequired"
|
||||
| "duplicateFrom"
|
||||
| "duplicateScope"
|
||||
| "unsupportedFrom"
|
||||
| "unsupportedTo";
|
||||
| "unsupportedTo"
|
||||
| "unsupportedMatchType";
|
||||
|
||||
export type ReasoningEffortMappingErrors = Record<
|
||||
string,
|
||||
Partial<Record<"from" | "to", ReasoningEffortMappingErrorCode>>
|
||||
Partial<
|
||||
Record<
|
||||
"from" | "to" | "match_type" | "duplicateScope",
|
||||
ReasoningEffortMappingErrorCode
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
let nextMappingRowID = 0;
|
||||
|
||||
export function createReasoningEffortMappingPair(
|
||||
pair: Partial<Pick<ReasoningEffortMapping, "from" | "to">> = {},
|
||||
): ReasoningEffortMappingPair {
|
||||
nextMappingRowID += 1;
|
||||
return {
|
||||
id: `reasoning-effort-pair-${nextMappingRowID}`,
|
||||
from: pair.from ?? "",
|
||||
to: pair.to ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function createReasoningEffortMappingRow(
|
||||
mapping: Partial<ReasoningEffortMapping> = {},
|
||||
mapping: Partial<Omit<ReasoningEffortMapping, "match_type">> & {
|
||||
match_type?: ReasoningEffortMatchType | "";
|
||||
} = {},
|
||||
): ReasoningEffortMappingRow {
|
||||
nextMappingRowID += 1;
|
||||
return {
|
||||
id: `reasoning-effort-mapping-${nextMappingRowID}`,
|
||||
from: mapping.from ?? "",
|
||||
to: mapping.to ?? "",
|
||||
id: `reasoning-effort-group-${nextMappingRowID}`,
|
||||
match_type: normalizeReasoningEffortMatchType(mapping.match_type),
|
||||
model: mapping.model?.trim() ?? "",
|
||||
pairs: [
|
||||
createReasoningEffortMappingPair({
|
||||
from: mapping.from,
|
||||
to: mapping.to,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mappingScopeKey(
|
||||
matchType: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
): string {
|
||||
const trimmedModel = model?.trim().toLowerCase() ?? "";
|
||||
const normalizedType = trimmedModel
|
||||
? normalizeReasoningEffortMatchType(matchType) || "exact"
|
||||
: "";
|
||||
return `${normalizedType}\0${trimmedModel}`;
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToRows(
|
||||
mappings?: ReasoningEffortMapping[] | null,
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingRow[] {
|
||||
return (mappings ?? []).flatMap((mapping) => {
|
||||
const groups: ReasoningEffortMappingRow[] = [];
|
||||
const indexByScope = new Map<string, number>();
|
||||
|
||||
(mappings ?? []).forEach((mapping) => {
|
||||
const from = normalizeReasoningEffortForPlatform(platform, mapping.from);
|
||||
const to = normalizeReasoningEffortForPlatform(platform, mapping.to);
|
||||
return from && to
|
||||
? [createReasoningEffortMappingRow({ from, to })]
|
||||
: [];
|
||||
if (!from || !to) return;
|
||||
|
||||
const matchType = normalizeReasoningEffortMatchType(mapping.match_type);
|
||||
const model = mapping.model?.trim() ?? "";
|
||||
const scope = mappingScopeKey(matchType, model);
|
||||
const existingIndex = indexByScope.get(scope);
|
||||
if (existingIndex !== undefined) {
|
||||
groups[existingIndex].pairs.push(
|
||||
createReasoningEffortMappingPair({ from, to }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = createReasoningEffortMappingRow({
|
||||
from,
|
||||
to,
|
||||
match_type: matchType,
|
||||
model,
|
||||
});
|
||||
indexByScope.set(scope, groups.length);
|
||||
groups.push(group);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function reasoningEffortMappingsToAPI(
|
||||
rows: ReasoningEffortMappingRow[],
|
||||
): ReasoningEffortMapping[] {
|
||||
return rows.map((row) => ({
|
||||
from: row.from.trim(),
|
||||
to: row.to.trim(),
|
||||
}));
|
||||
return rows.flatMap((row) => {
|
||||
const model = row.model.trim();
|
||||
return row.pairs.map((pair) => {
|
||||
const mapping: ReasoningEffortMapping = {
|
||||
from: pair.from.trim(),
|
||||
to: pair.to.trim(),
|
||||
};
|
||||
if (model) {
|
||||
mapping.match_type =
|
||||
normalizeReasoningEffortMatchType(row.match_type) || "exact";
|
||||
mapping.model = model;
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function validateReasoningEffortMappings(
|
||||
@@ -108,30 +207,49 @@ export function validateReasoningEffortMappings(
|
||||
platform: GroupPlatform = "openai",
|
||||
): ReasoningEffortMappingErrors {
|
||||
const errors: ReasoningEffortMappingErrors = {};
|
||||
const sourceRows = new Map<string, ReasoningEffortMappingRow[]>();
|
||||
const scopeGroups = new Map<string, ReasoningEffortMappingRow[]>();
|
||||
const sourcePairs = new Map<string, ReasoningEffortMappingPair[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const from = row.from.trim();
|
||||
const to = row.to.trim();
|
||||
if (!from) {
|
||||
errors[row.id] = { ...errors[row.id], from: "fromRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, from)) {
|
||||
errors[row.id] = { ...errors[row.id], from: "unsupportedFrom" };
|
||||
} else {
|
||||
const key = from.toLowerCase();
|
||||
sourceRows.set(key, [...(sourceRows.get(key) ?? []), row]);
|
||||
}
|
||||
if (!to) {
|
||||
errors[row.id] = { ...errors[row.id], to: "toRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
|
||||
errors[row.id] = { ...errors[row.id], to: "unsupportedTo" };
|
||||
const rawMatchType = row.match_type.trim().toLowerCase();
|
||||
const matchType = normalizeReasoningEffortMatchType(row.match_type);
|
||||
if (rawMatchType && !matchType) {
|
||||
errors[row.id] = { ...errors[row.id], match_type: "unsupportedMatchType" };
|
||||
}
|
||||
|
||||
const scope = mappingScopeKey(row.match_type, row.model);
|
||||
scopeGroups.set(scope, [...(scopeGroups.get(scope) ?? []), row]);
|
||||
|
||||
row.pairs.forEach((pair) => {
|
||||
const from = pair.from.trim();
|
||||
const to = pair.to.trim();
|
||||
if (!from) {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "fromRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, from)) {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "unsupportedFrom" };
|
||||
} else {
|
||||
const key = `${scope}\0${from.toLowerCase()}`;
|
||||
sourcePairs.set(key, [...(sourcePairs.get(key) ?? []), pair]);
|
||||
}
|
||||
if (!to) {
|
||||
errors[pair.id] = { ...errors[pair.id], to: "toRequired" };
|
||||
} else if (!normalizeReasoningEffortForPlatform(platform, to)) {
|
||||
errors[pair.id] = { ...errors[pair.id], to: "unsupportedTo" };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sourceRows.forEach((duplicateRows) => {
|
||||
if (duplicateRows.length < 2) return;
|
||||
duplicateRows.forEach((row) => {
|
||||
errors[row.id] = { ...errors[row.id], from: "duplicateFrom" };
|
||||
scopeGroups.forEach((duplicateGroups) => {
|
||||
if (duplicateGroups.length < 2) return;
|
||||
duplicateGroups.forEach((row) => {
|
||||
errors[row.id] = { ...errors[row.id], duplicateScope: "duplicateScope" };
|
||||
});
|
||||
});
|
||||
|
||||
sourcePairs.forEach((duplicatePairs) => {
|
||||
if (duplicatePairs.length < 2) return;
|
||||
duplicatePairs.forEach((pair) => {
|
||||
errors[pair.id] = { ...errors[pair.id], from: "duplicateFrom" };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user