From 7969751f1f0aaa262d7c0a2550d0f12c0fed8a35 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 5 Sep 2026 12:32:30 +0800 Subject: [PATCH] feat(openai): allow none as reasoning mapping source --- .../service/openai_reasoning_effort_policy.go | 24 ++++++++--- .../openai_reasoning_effort_policy_test.go | 24 ++++++++++- .../group/ReasoningEffortPolicyFields.vue | 6 ++- .../ReasoningEffortPolicyFields.spec.ts | 42 +++++++++++++++++++ .../__tests__/groupsReasoningEffort.spec.ts | 29 +++++++++++++ .../src/views/admin/groupsReasoningEffort.ts | 31 +++++++++++++- 6 files changed, 146 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index caa1291120..b2b3aae6e8 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -23,6 +23,13 @@ const ( var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +func normalizeReasoningEffortMappingSource(raw string) string { + if strings.EqualFold(strings.TrimSpace(raw), "none") { + return "none" + } + return NormalizeMaxReasoningEffort(raw) +} + type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -220,7 +227,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } candidates := make([]candidate, 0, len(mappings)) for i, mapping := range mappings { - if NormalizeMaxReasoningEffort(mapping.From) != from { + if normalizeReasoningEffortMappingSource(mapping.From) != from { continue } model := strings.TrimSpace(mapping.Model) @@ -283,7 +290,7 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp normalized := make([]ReasoningEffortMapping, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) for i, mapping := range raw { - from := NormalizeMaxReasoningEffort(mapping.From) + from := normalizeReasoningEffortMappingSource(mapping.From) to := NormalizeMaxReasoningEffort(mapping.To) if from == "" || to == "" { return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) @@ -291,8 +298,15 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp if len(from) > maxReasoningEffortValueLen || len(to) > maxReasoningEffortValueLen { return nil, fmt.Errorf("reasoning effort mapping %d values cannot exceed %d characters", i+1, maxReasoningEffortValueLen) } - if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil { - return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err) + if from != "none" { + if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err) + } + } else if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return nil, fmt.Errorf( + "reasoning effort mapping %d source: reasoning effort policy is only supported for platforms %q and %q", + i+1, PlatformOpenAI, PlatformComposite, + ) } if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) @@ -383,7 +397,7 @@ func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byt func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) { value := strings.TrimSpace(raw) - canonical := NormalizeMaxReasoningEffort(value) + canonical := normalizeReasoningEffortMappingSource(value) if canonical == "" { return value, false } diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index fae609d7c1..baab02c27f 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -152,10 +152,18 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") } - _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) require.ErrorContains(t, err, "empty or unknown") + }) - _, err = NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) + t.Run("allows none only as a source for OpenAI routes", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformComposite} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " NONE ", To: "low"}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "none", To: "low"}}, got) + } + + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "low", To: "none"}}) require.ErrorContains(t, err, "empty or unknown") }) } @@ -252,6 +260,15 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {name: "caps both shapes", body: `{"reasoning":{"effort":"high"},"reasoning_effort":"xhigh"}`, max: "low", path: "reasoning.effort", want: "low", changed: true}, {name: "maps before cap", body: `{"reasoning":{"effort":"MAX"}}`, max: "medium", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "medium", changed: true}, {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: "maps nested none", body: `{"reasoning":{"effort":"none"}}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning.effort", want: "low", changed: true}, + {name: "maps flat none", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "none mapping runs before cap", body: `{"reasoning_effort":"none"}`, max: "low", mappings: []ReasoningEffortMapping{{From: "none", To: "high"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "keeps none byte for byte without mappings", body: `{ "reasoning_effort": " none " }`, max: "low", path: "reasoning_effort", want: " none ", changed: false}, + {name: "keeps none byte for byte when scope does not match", body: `{ "model":"o3", "reasoning_effort":"none" }`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "none", changed: false}, + {name: "exact scope maps none", body: `{"model":"gpt-5","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "prefix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "suffix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "global scope maps none", body: `{"model":"o3","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", 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}, { @@ -398,6 +415,9 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { } require.NoError(t, err) require.Equal(t, tt.changed, changed) + if !tt.changed { + require.Equal(t, tt.body, string(got)) + } if tt.path != "" { require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String()) } diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index 4b80d73fca..b16107162f 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -136,7 +136,7 @@