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") }} +

+
+
+ + + +
+
+ +
+ +
@@ -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 }] : []; + }), ); }; diff --git a/frontend/src/components/admin/group/__tests__/ReasoningEffortPolicyFields.spec.ts b/frontend/src/components/admin/group/__tests__/ReasoningEffortPolicyFields.spec.ts new file mode 100644 index 0000000000..f407e1995a --- /dev/null +++ b/frontend/src/components/admin/group/__tests__/ReasoningEffortPolicyFields.spec.ts @@ -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("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: '' }, + Select: { + props: ["id", "modelValue", "placeholder"], + template: + '', + }, + }, + }, + }); + + 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: '' }, + Select: { template: "
" }, + }, + }, + }); + + 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: "", + }), + ], + }), + ]); + }); +}); diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 4f2f8df74e..a31cb65e10 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 7d2cb24c9b..344bbc41f7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -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: '平台限制', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8fd0673f13..1c2fcc6f18 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -544,9 +544,13 @@ export interface OpenAIMessagesDispatchModelConfig { exact_model_mappings?: Record } +export type ReasoningEffortMatchType = 'exact' | 'prefix' | 'suffix' + export interface ReasoningEffortMapping { from: string to: string + match_type?: ReasoningEffortMatchType + model?: string } export interface Group { diff --git a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts index 45f52cf0d1..663d7cefd3 100644 --- a/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsReasoningEffort.spec.ts @@ -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" }, }); }); diff --git a/frontend/src/views/admin/groupsReasoningEffort.ts b/frontend/src/views/admin/groupsReasoningEffort.ts index 5afa6d993b..de2fbbee4b 100644 --- a/frontend/src/views/admin/groupsReasoningEffort.ts +++ b/frontend/src/views/admin/groupsReasoningEffort.ts @@ -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> + Partial< + Record< + "from" | "to" | "match_type" | "duplicateScope", + ReasoningEffortMappingErrorCode + > + > >; let nextMappingRowID = 0; +export function createReasoningEffortMappingPair( + pair: Partial> = {}, +): ReasoningEffortMappingPair { + nextMappingRowID += 1; + return { + id: `reasoning-effort-pair-${nextMappingRowID}`, + from: pair.from ?? "", + to: pair.to ?? "", + }; +} + export function createReasoningEffortMappingRow( - mapping: Partial = {}, + mapping: Partial> & { + 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(); + + (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(); + const scopeGroups = new Map(); + const sourcePairs = new Map(); 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" }; }); });