diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 6e613aa3e493..d1ce35fefb4a 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -163,16 +163,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s return } bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String())) - // The Messages bridge synthesizes a default OpenAI effort when - // output_config.effort is omitted. Bind the group policy only for an - // explicit client value so the ceiling does not alter that default. - effort := gjson.GetBytes(body, "output_config.effort") - if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" { - return - } maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) if !ok { return } - c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit)) + c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicyForMessages(c.Request.Context(), body, maxEffort, mappings, overLimit)) } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 7704a129a08e..ee3d4ad8594f 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -133,14 +133,34 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { require.True(t, changed) require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound)) - omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) - omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil) - omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI)) - bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`)) - omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body) - require.NoError(t, err) - require.False(t, changed) - require.Equal(t, body, omitted) + synthesizedGroup := &service.Group{ + Platform: service.PlatformComposite, + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "medium", To: "low"}, + {From: "none", To: "low"}, + }, + } + synthesizedAPIKey := &service.APIKey{Group: synthesizedGroup} + for _, tc := range []struct { + name string + request string + response string + want string + }{ + {name: "default medium", request: `{"model":"gpt-5"}`, response: `{"reasoning":{"effort":"medium"}}`, want: "medium"}, + {name: "disabled thinking none", request: `{"model":"gpt-5","thinking":{"type":"disabled"}}`, response: `{"reasoning":{"effort":"none"}}`, want: "low"}, + } { + t.Run(tc.name, func(t *testing.T) { + omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil) + omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI)) + bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, synthesizedAPIKey, []byte(tc.request)) + got, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), []byte(tc.response)) + require.NoError(t, err) + require.Equal(t, tc.want != "medium", changed) + require.Equal(t, tc.want, gjson.GetBytes(got, "reasoning.effort").String()) + }) + } denyGroup := *group denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny diff --git a/backend/internal/handler/messages_effort_policy_test.go b/backend/internal/handler/messages_effort_policy_test.go new file mode 100644 index 000000000000..63734b7e04bb --- /dev/null +++ b/backend/internal/handler/messages_effort_policy_test.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMessagesEffortPolicyBinding(t *testing.T) { + for _, platform := range []string{service.PlatformOpenAI, service.PlatformComposite} { + for _, test := range []struct { + name string + preference string + effort string + mappings []service.ReasoningEffortMapping + ceiling string + want string + }{ + {name: "omitted unchanged", effort: "medium", want: "medium"}, + {name: "omitted ignores medium mapping", effort: "medium", mappings: []service.ReasoningEffortMapping{{From: "medium", To: "low"}}, want: "medium"}, + {name: "omitted ignores ceiling", effort: "medium", ceiling: "low", want: "medium"}, + {name: "omitted none mapping does not cap medium default", effort: "medium", ceiling: "low", mappings: []service.ReasoningEffortMapping{{From: "none", To: "low"}}, want: "medium"}, + {name: "disabled omitted applies none only once", preference: `,"thinking":{"type":"disabled"}`, effort: "none", mappings: []service.ReasoningEffortMapping{{From: "none", To: "low"}, {From: "low", To: "high"}}, want: "low"}, + {name: "disabled explicit applies none", preference: `,"output_config":{"effort":"max"},"thinking":{"type":"disabled"}`, effort: "none", mappings: []service.ReasoningEffortMapping{{From: "none", To: "low"}}, want: "low"}, + {name: "explicit adaptive retains policy", preference: `,"output_config":{"effort":"high"},"thinking":{"type":"adaptive"}`, effort: "high", ceiling: "medium", want: "medium"}, + {name: "omitted retains model scope", preference: `,"thinking":{"type":"disabled"}`, effort: "none", mappings: []service.ReasoningEffortMapping{{From: "none", To: "low", Model: "gpt-6-sol", MatchType: "exact"}}, want: "none"}, + } { + t.Run(platform+"/"+test.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: platform, MaxReasoningEffort: test.ceiling, ReasoningEffortMappings: test.mappings}} + body := []byte(`{"model":"gpt-6.1-sol"` + test.preference + `}`) + ensureCompositeTargetPlatform(c, apiKey, "gpt-6.1-sol") + bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) + converted := []byte(`{"model":"gpt-6.1-sol","reasoning":{"effort":"` + test.effort + `"}}`) + got, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(c.Request.Context(), converted) + require.NoError(t, err) + require.Equal(t, test.want, gjson.GetBytes(got, "reasoning.effort").String()) + require.Equal(t, test.effort != test.want, changed) + if !changed { + require.Equal(t, converted, got) + } + }) + } + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_effort_validation_test.go b/backend/internal/pkg/apicompat/anthropic_effort_validation_test.go new file mode 100644 index 000000000000..d72ce9e907f5 --- /dev/null +++ b/backend/internal/pkg/apicompat/anthropic_effort_validation_test.go @@ -0,0 +1,35 @@ +package apicompat + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAnthropicToResponsesDeferredEffortValidation(t *testing.T) { + for _, effort := range []string{"", "low", "high", "max"} { + t.Run(effort, func(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-6.1-sol", + MaxTokens: 1, + Thinking: &AnthropicThinking{Type: "disabled"}, + OutputConfig: &AnthropicOutputConfig{Effort: effort}, + } + _, err := AnthropicToResponses(req) + require.Error(t, err) + _, err = AnthropicToResponsesWithMinOutputTokens(req, 32) + require.Error(t, err) + converted, err := AnthropicToResponsesWithOptions(req, AnthropicToResponsesOptions{ + MinOutputTokens: 32, + DeferReasoningEffortValidation: true, + }) + require.NoError(t, err) + require.Equal(t, "gpt-6.1-sol", converted.Model) + require.Equal(t, "none", converted.Reasoning.Effort) + require.Empty(t, converted.Reasoning.Summary) + require.Equal(t, 32, *converted.MaxOutputTokens) + require.Equal(t, "disabled", req.Thinking.Type) + require.Equal(t, effort, req.OutputConfig.Effort) + }) + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index a54ca94133cd..aabeacf37f79 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -19,8 +19,22 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { // AnthropicToResponsesWithMinOutputTokens converts an Anthropic request using // the supplied max_output_tokens floor. func AnthropicToResponsesWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ResponsesRequest, error) { - if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil { - return nil, err + return AnthropicToResponsesWithOptions(req, AnthropicToResponsesOptions{MinOutputTokens: minOutputTokens}) +} + +// AnthropicToResponsesOptions controls conversion and optional deferred effort +// validation. Callers deferring validation must validate raw selections before +// conversion and the final effort after applying their policy. +type AnthropicToResponsesOptions struct { + MinOutputTokens int + DeferReasoningEffortValidation bool +} + +func AnthropicToResponsesWithOptions(req *AnthropicRequest, options AnthropicToResponsesOptions) (*ResponsesRequest, error) { + if !options.DeferReasoningEffortValidation { + if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil { + return nil, err + } } input, err := convertAnthropicToResponsesInput(req.System, req.Messages) if err != nil { @@ -56,8 +70,8 @@ func AnthropicToResponsesWithMinOutputTokens(req *AnthropicRequest, minOutputTok if req.MaxTokens > 0 { v := req.MaxTokens - if v < minOutputTokens { - v = minOutputTokens + if v < options.MinOutputTokens { + v = options.MinOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/service/apicompat_config.go b/backend/internal/service/apicompat_config.go index e9605e0a5c0d..8d7680594c0b 100644 --- a/backend/internal/service/apicompat_config.go +++ b/backend/internal/service/apicompat_config.go @@ -16,10 +16,6 @@ func chatCompletionsToResponses(cfg *config.Config, req *apicompat.ChatCompletio return apicompat.ChatCompletionsToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) } -func anthropicToResponses(cfg *config.Config, req *apicompat.AnthropicRequest) (*apicompat.ResponsesRequest, error) { - return apicompat.AnthropicToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) -} - func settingServiceConfig(settingService *SettingService) *config.Config { if settingService == nil { return nil diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 45f890a37065..76c125fc0ee6 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -98,7 +98,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 2. Model mapping billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { + if err := validateGPT61SolRawEffort(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -154,7 +154,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Convert Anthropic → Responses after compatibility-only replay guard. anthropicReq.Model = upstreamModel - responsesReq, err := anthropicToResponses(s.cfg, &anthropicReq) + responsesReq, err := apicompat.AnthropicToResponsesWithOptions(&anthropicReq, apicompat.AnthropicToResponsesOptions{ + MinOutputTokens: configuredMinOutputTokens(s.cfg), + DeferReasoningEffortValidation: true, + }) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) } @@ -315,6 +318,11 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } + if err := validateGPT61SolCompatRequest(responsesBody, upstreamModel); err != nil { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + // 4c. Apply OpenAI fast policy (may filter service_tier or block the request). // Mirrors the Claude anthropic-beta "fast-mode-2026-02-01" filter, but keyed // on the body-level service_tier field (priority/flex). diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index d12918a3bb68..2eca13ddcac3 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -63,7 +63,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( billingModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { + if err := validateGPT61SolRawEffort(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -101,6 +101,10 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatBody = policyBody } } + if err := validateGPT61SolCompatRequest(chatBody, upstreamModel); err != nil { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // Provider normalization and policy caps can both change the converted effort. // Use the final outbound value for usage logs and billing. reasoningEffort := extractOpenAIReasoningEffortFromBody(chatBody, upstreamModel, billingModel, originalModel) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 38cc743d9df1..24bcb517b93a 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -2604,6 +2604,16 @@ func supportsOpenAIReasoningEffortMax(model string) bool { // validateGPT61SolCompatRequest runs after model mapping, before conversion can // discard unsupported explicit effort selections or disabled thinking. func validateGPT61SolCompatRequest(body []byte, model string) error { + if err := validateGPT61SolRawEffort(body, model); err != nil { + return err + } + if gjson.GetBytes(body, "thinking.type").String() == "disabled" { + return openai.ValidateGPT61SolReasoningEffort(model, "none") + } + return nil +} + +func validateGPT61SolRawEffort(body []byte, model string) error { if !openai.IsGPT61SolModelSpelling(model) { return nil } @@ -2612,9 +2622,6 @@ func validateGPT61SolCompatRequest(body []byte, model string) error { return err } } - if gjson.GetBytes(body, "thinking.type").String() == "disabled" { - return openai.ValidateGPT61SolReasoningEffort(model, "none") - } requestedModel := gjson.GetBytes(body, "model").String() for _, effort := range []string{"none", "minimal"} { if strings.HasSuffix(strings.ToLower(requestedModel), "-"+effort) { diff --git a/backend/internal/service/openai_messages_effort_policy_test.go b/backend/internal/service/openai_messages_effort_policy_test.go new file mode 100644 index 000000000000..934de147ef02 --- /dev/null +++ b/backend/internal/service/openai_messages_effort_policy_test.go @@ -0,0 +1,156 @@ +//go:build unit + +package service + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMessagesEffortPolicyHTTP(t *testing.T) { + gin.SetMode(gin.TestMode) + noneToLow := []ReasoningEffortMapping{{From: "none", To: "low"}} + for _, chat := range []bool{false, true} { + for _, auth := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + for _, stream := range []bool{false, true} { + for _, test := range []struct { + name string + preference string + mappings []ReasoningEffortMapping + ceiling string + model string + terminal bool + want string + status int + }{ + {name: "omitted unchanged", want: "medium"}, + {name: "omitted unrelated mapping unchanged", mappings: []ReasoningEffortMapping{{From: "medium", To: "low"}}, want: "medium"}, + {name: "omitted ceiling unchanged", ceiling: "low", want: "medium"}, + {name: "omitted none mapping and ceiling unchanged", mappings: noneToLow, ceiling: "low", want: "medium"}, + {name: "omitted none mapping leaves default", mappings: noneToLow, want: "medium"}, + {name: "disabled omitted mapped", preference: `,"thinking":{"type":"disabled"}`, mappings: noneToLow, want: "low"}, + {name: "disabled low mapped", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"low"}`, mappings: noneToLow, want: "low"}, + {name: "disabled high mapped", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"}`, mappings: noneToLow, want: "low"}, + {name: "disabled max mapped", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"max"}`, mappings: noneToLow, want: "low"}, + {name: "disabled no mapping rejected", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"}`, status: 400}, + {name: "disabled omitted no mapping rejected", preference: `,"thinking":{"type":"disabled"}`, status: 400}, + {name: "disabled unrelated mapping rejected", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"}`, mappings: []ReasoningEffortMapping{{From: "high", To: "low"}}, status: 400}, + {name: "disabled wrong model scope rejected", preference: `,"thinking":{"type":"disabled"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", Model: "gpt-6-sol", MatchType: "exact"}}, status: 400}, + {name: "raw none cannot be mapped away", preference: `,"output_config":{"effort":"none"}`, mappings: noneToLow, status: 400}, + {name: "raw none disabled cannot be hidden", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"none"}`, mappings: noneToLow, status: 400}, + {name: "raw minimal disabled cannot be hidden", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"minimal"}`, mappings: append(append([]ReasoningEffortMapping{}, noneToLow...), ReasoningEffortMapping{From: "minimal", To: "low"}), status: 400}, + {name: "raw minimal cannot be mapped away", preference: `,"output_config":{"effort":"minimal"}`, mappings: []ReasoningEffortMapping{{From: "minimal", To: "low"}}, status: 400}, + {name: "raw reasoning none cannot be hidden", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"},"reasoning":{"effort":"none"}`, mappings: noneToLow, status: 400}, + {name: "raw chat minimal cannot be hidden", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"},"reasoning_effort":"minimal"`, mappings: noneToLow, status: 400}, + {name: "raw alias cannot be hidden", model: "gpt-6.1-sol-minimal", preference: `,"thinking":{"type":"disabled"}`, mappings: noneToLow, status: 400}, + {name: "one hop mapping", preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}, {From: "low", To: "high"}}, want: "low"}, + {name: "mapping final unsupported rejected", preference: `,"thinking":{"type":"disabled"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "minimal"}}, status: 400}, + {name: "mapping deny preserved", preference: `,"thinking":{"type":"disabled"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "deny"}}, status: 403}, + {name: "adaptive explicit unchanged", preference: `,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}`, mappings: noneToLow, want: "high"}, + {name: "adaptive explicit mapped", preference: `,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}`, mappings: []ReasoningEffortMapping{{From: "high", To: "low"}, {From: "low", To: "medium"}}, want: "low"}, + {name: "explicit ceiling preserved", preference: `,"output_config":{"effort":"high"}`, ceiling: "medium", want: "medium"}, + {name: "terminal override not mapped twice", terminal: true, preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"high"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", Model: "gpt-6.1-sol", MatchType: "exact"}}, want: "low"}, + {name: "terminal override raw rejected", terminal: true, preference: `,"thinking":{"type":"disabled"},"output_config":{"effort":"none"}`, mappings: noneToLow, status: 400}, + } { + t.Run(fmt.Sprintf("chat=%t/%s/stream=%t/%s", chat, auth, stream, test.name), func(t *testing.T) { + model := test.model + if model == "" { + model = "gpt-6.1-sol" + } + account := rawGPT56ResponsesAPIKeyAccount(model, "gpt-6.1-sol") + if auth == AccountTypeOAuth { + account = rawGPT56ResponsesOAuthAccount(model, "gpt-6.1-sol") + } + if chat { + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions)} + } + defaultMapped := "" + if test.terminal { + model = "public-alias" + defaultMapped = "gpt-6.1-sol" + account.Credentials["model_mapping"] = map[string]any{"gpt-6.1-sol": "gpt-6-sol"} + } + usesChat := chat && auth == AccountTypeAPIKey + response := openAICompatSSECompletedResponse("resp_effort", "gpt-6.1-sol") + if usesChat { + payload := `{"id":"chat_effort","model":"gpt-6.1-sol","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}` + contentType := "application/json" + if stream { + payload = "data: " + `{"id":"chat_effort","model":"gpt-6.1-sol","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}` + "\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + response = &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(payload))} + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + body := fmt.Sprintf(`{"model":%q,"max_tokens":1024,"messages":[{"role":"user","content":"Hello"}],"stream":%t%s}`, model, stream, test.preference) + var forwardErr error + var result *OpenAIForwardResult + router := gin.New() + router.POST("/v1/messages", func(c *gin.Context) { + ctx := WithOpenAIReasoningEffortPolicyForMessages(c.Request.Context(), []byte(body), test.ceiling, test.mappings, "") + result, forwardErr = svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", defaultMapped) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))) + if test.status != 0 { + require.Error(t, forwardErr) + require.Equal(t, test.status, recorder.Code, recorder.Body.String()) + require.Empty(t, upstream.requests) + return + } + preferredEffortPath := "reasoning.effort" + endpoint := "/responses" + if usesChat { + preferredEffortPath = "reasoning_effort" + endpoint = "/chat/completions" + } + require.NoError(t, forwardErr) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Len(t, upstream.requests, 1) + require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, endpoint), upstream.lastReq.URL.String()) + require.Equal(t, "gpt-6.1-sol", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, test.want, gjson.GetBytes(upstream.lastBody, preferredEffortPath).String()) + require.NotNil(t, result) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, test.want, *result.ReasoningEffort) + require.Equal(t, stream, result.Stream) + require.Equal(t, stream || !usesChat, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + if stream { + require.Contains(t, recorder.Header().Get("Content-Type"), "text/event-stream") + require.Contains(t, recorder.Body.String(), "message_stop") + } else { + require.Contains(t, recorder.Header().Get("Content-Type"), "application/json") + require.Equal(t, "ok", gjson.GetBytes(recorder.Body.Bytes(), "content.0.text").String()) + } + }) + } + } + } + } +} + +func TestMessagesRawEffortValidation(t *testing.T) { + for _, test := range []struct { + body string + deny bool + }{ + {body: `{"output_config":{"effort":"high"},"thinking":{"type":"disabled"}}`}, + {body: `{"output_config":{"effort":"none"},"thinking":{"type":"disabled"}}`, deny: true}, + {body: `{"reasoning_effort":"none"}`, deny: true}, + {body: `{"reasoning":{"effort":"minimal"}}`, deny: true}, + {body: `{"model":"gpt-6.1-sol-none"}`, deny: true}, + } { + require.Equal(t, test.deny, validateGPT61SolRawEffort([]byte(test.body), "gpt-6.1-sol") != nil, test.body) + require.NoError(t, validateGPT61SolRawEffort([]byte(test.body), "gpt-6-sol")) + } +} diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 907025597b4a..548aafea972f 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -445,6 +445,22 @@ func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mapp return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy) } +// WithOpenAIReasoningEffortPolicyForMessages preserves bridge defaults when the +// client omits output_config.effort, binding only mappings for synthesized none. +func WithOpenAIReasoningEffortPolicyForMessages(ctx context.Context, body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) context.Context { + if strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String()) == "" { + maxEffort = "" + noneMappings := make([]ReasoningEffortMapping, 0, len(mappings)) + for _, mapping := range mappings { + if normalizeReasoningEffortMappingSource(mapping.From) == "none" { + noneMappings = append(noneMappings, mapping) + } + } + mappings = noneMappings + } + return WithOpenAIReasoningEffortPolicy(ctx, maxEffort, mappings, overLimit) +} + // ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound // to the request. An unbound request is returned byte-for-byte unchanged. func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool, error) {