diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 6e613aa3e493..60cbc1a02d41 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -68,7 +68,7 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe return "", nil, "", false } effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) - if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI { + if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI && !service.IsCNProvider(effectivePlatform) { return "", nil, "", false } maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings @@ -85,7 +85,7 @@ func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.AP if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite { return "", nil, "", false } - if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic { + if platform := effectiveAPIKeyPlatform(c, apiKey); platform != service.PlatformAnthropic && !service.IsCNProvider(platform) { return "", nil, "", false } maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings) @@ -142,6 +142,17 @@ func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service. return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit) } +func applyOpenAIChatCompletionsReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) { + updated, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body) + if err != nil || c == nil || c.Request == nil { + return updated, changed, err + } + if maxEffort, _, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey); ok { + c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, nil, overLimit)) + } + return updated, changed, nil +} + func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) { if c == nil || err == nil || write == nil { return @@ -174,5 +185,8 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s if !ok { return } + if service.IsCNProvider(effectiveAPIKeyPlatform(c, apiKey)) { + maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings) + } c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit)) } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 7704a129a08e..55f122838ea1 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -248,6 +248,77 @@ func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) { require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings) } +func TestCompositeCNReasoningEffortPolicies(testContext *testing.T) { + gin.SetMode(gin.TestMode) + for _, platform := range []string{service.PlatformDeepseek, service.PlatformKimi, service.PlatformZhipu, service.PlatformMiniMax, service.PlatformGrok, service.PlatformGemini, service.PlatformOpenCodeGo, service.PlatformCommandCode, service.PlatformCline} { + testContext.Run(platform, func(testContext *testing.T) { + ginContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginContext.Request = httptest.NewRequest("POST", "/v1/messages", nil) + ginContext.Request = ginContext.Request.WithContext(service.WithResolvedTargetPlatform(ginContext.Request.Context(), platform)) + apiKey := &service.APIKey{Group: &service.Group{ + Platform: service.PlatformComposite, + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "low", To: "max", MatchType: "exact", Model: "deepseek-flash"}, + }, + }} + body := []byte(`{"model":"deepseek-flash","output_config":{"effort":"low"}}`) + wantEffort := "low" + if service.IsCNProvider(platform) { + wantEffort = "max" + } + for _, apply := range []func(*gin.Context, *service.APIKey, []byte) ([]byte, bool, error){applyOpenAIReasoningEffortPolicyForRequest, applyAnthropicReasoningEffortPolicyForRequest} { + got, changed, err := apply(ginContext, apiKey, body) + require.NoError(testContext, err) + require.Equal(testContext, service.IsCNProvider(platform), changed) + require.Equal(testContext, wantEffort, gjson.GetBytes(got, "output_config.effort").String()) + } + bindOpenAIReasoningEffortPolicyForMessagesRequest(ginContext, apiKey, body) + got, _, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(ginContext.Request.Context(), body) + require.NoError(testContext, err) + require.Equal(testContext, wantEffort, gjson.GetBytes(got, "output_config.effort").String()) + if !service.IsCNProvider(platform) { + return + } + require.Equal(testContext, "low", *service.RequestedReasoningEffortFromContext(ginContext.Request.Context())) + apiKey.Group.ReasoningEffortMappings[0].To = "minimal" + openAIBody, _, err := applyOpenAIReasoningEffortPolicyForRequest(ginContext, apiKey, []byte(`{"model":"deepseek-flash","reasoning_effort":"low"}`)) + require.NoError(testContext, err) + require.Equal(testContext, "minimal", gjson.GetBytes(openAIBody, "reasoning_effort").String()) + bindOpenAIReasoningEffortPolicyForMessagesRequest(ginContext, apiKey, body) + got, _, err = service.ApplyOpenAIReasoningEffortPolicyFromContext(ginContext.Request.Context(), body) + require.NoError(testContext, err) + require.Equal(testContext, "low", gjson.GetBytes(got, "output_config.effort").String()) + require.Equal(testContext, "minimal", apiKey.Group.ReasoningEffortMappings[0].To) + apiKey.Group.MaxReasoningEffort = "minimal" + maxEffort, _, _, ok := anthropicReasoningEffortPolicyForRequest(ginContext, apiKey) + require.True(testContext, ok) + require.Equal(testContext, "low", maxEffort) + ceilingBody := []byte(`{"model":"deepseek-flash","output_config":{"effort":"max"}}`) + bindOpenAIReasoningEffortPolicyForMessagesRequest(ginContext, apiKey, ceilingBody) + got, _, err = service.ApplyOpenAIReasoningEffortPolicyFromContext(ginContext.Request.Context(), ceilingBody) + require.NoError(testContext, err) + require.Equal(testContext, "low", gjson.GetBytes(got, "output_config.effort").String()) + fresh, _ := gin.CreateTestContext(httptest.NewRecorder()) + fresh.Request = httptest.NewRequest("POST", "/v1/messages", nil) + fresh.Request = fresh.Request.WithContext(service.WithResolvedTargetPlatform(fresh.Request.Context(), platform)) + omitted := []byte(`{"model":"deepseek-flash","thinking":{"type":"enabled"}}`) + bindOpenAIReasoningEffortPolicyForMessagesRequest(fresh, apiKey, omitted) + got, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(fresh.Request.Context(), omitted) + require.NoError(testContext, err) + require.False(testContext, changed) + require.Equal(testContext, omitted, got) + apiKey.Group.MaxReasoningEffort = "" + apiKey.Group.ReasoningEffortMappings = nil + for _, apply := range []func(*gin.Context, *service.APIKey, []byte) ([]byte, bool, error){applyOpenAIReasoningEffortPolicyForRequest, applyAnthropicReasoningEffortPolicyForRequest} { + got, changed, err := apply(fresh, apiKey, body) + require.NoError(testContext, err) + require.False(testContext, changed) + require.Equal(testContext, body, got) + } + }) + } +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 0a371f12ebd9..c1625defe21a 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -81,7 +81,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } - if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + if cappedBody, changed, err := applyOpenAIChatCompletionsReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) return } else if changed { diff --git a/backend/internal/handler/openai_chat_completions_reasoning_policy_test.go b/backend/internal/handler/openai_chat_completions_reasoning_policy_test.go new file mode 100644 index 000000000000..9bda34c92fa7 --- /dev/null +++ b/backend/internal/handler/openai_chat_completions_reasoning_policy_test.go @@ -0,0 +1,134 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type chatReasoningPolicyUpstream struct { + service.HTTPUpstream + bodies [][]byte +} + +func (upstream *chatReasoningPolicyUpstream) Do(request *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + upstream.bodies = append(upstream.bodies, body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_policy","object":"chat.completion","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)), + }, nil +} + +func TestChatCompletionsReasoningPolicyForwardedBoundary(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + name string + platform string + model string + finalModel string + effort string + ceiling string + overLimit string + mappings []service.ReasoningEffortMapping + wantEffort string + wantDenied bool + }{ + {name: "medium ceiling rejects GLM upgrade", effort: "medium", ceiling: "medium", wantDenied: true}, + {name: "medium ceiling denies GLM upgrade", effort: "medium", ceiling: "medium", overLimit: "deny", wantDenied: true}, + {name: "xhigh ceiling rejects GLM max", effort: "xhigh", ceiling: "xhigh", wantDenied: true}, + {name: "xhigh ceiling denies GLM max", effort: "xhigh", ceiling: "xhigh", overLimit: "deny", wantDenied: true}, + {name: "max input cannot bypass xhigh ceiling", effort: "max", ceiling: "xhigh", wantDenied: true}, + {name: "max downgraded to high", effort: "xhigh", ceiling: "high", wantEffort: "high"}, + {name: "max ceiling permits max", effort: "xhigh", ceiling: "max", wantEffort: "max"}, + {name: "explicit max remains max", effort: "max", ceiling: "max", wantEffort: "max"}, + {name: "deny max on ingress", effort: "max", ceiling: "high", overLimit: "deny", wantDenied: true}, + {name: "low ceiling incompatible with older GLM", effort: "medium", ceiling: "low", wantDenied: true}, + {name: "GLM 5.3 supports low ceiling", finalModel: "glm-5.3", effort: "medium", ceiling: "low", wantEffort: "low"}, + {name: "account alias resolves GLM", model: "deepseek-flash", platform: service.PlatformDeepseek, effort: "medium", ceiling: "medium", wantDenied: true}, + {name: "Kimi account resolves GLM", model: "kimi-k2-thinking", platform: service.PlatformKimi, effort: "xhigh", ceiling: "xhigh", wantDenied: true}, + {name: "MiniMax account resolves GLM", model: "MiniMax-M3", platform: service.PlatformMiniMax, effort: "medium", ceiling: "medium", wantDenied: true}, + {name: "GLM alias resolves non GLM", finalModel: "deepseek-flash", effort: "medium", ceiling: "medium", wantEffort: "medium"}, + {name: "mapping runs once before normalization", effort: "low", ceiling: "max", mappings: []service.ReasoningEffortMapping{ + {From: "low", To: "medium", MatchType: "exact", Model: "glm-5.2"}, + {From: "medium", To: "max", MatchType: "exact", Model: "glm-5.2"}, + {From: "high", To: "deny"}, + }, wantEffort: "high"}, + {name: "mapping matches client not final model", effort: "low", ceiling: "max", mappings: []service.ReasoningEffortMapping{ + {From: "low", To: "medium", MatchType: "exact", Model: "glm-5.2"}, + {From: "medium", To: "deny", MatchType: "exact", Model: "glm-5.3"}, + {From: "high", To: "deny", MatchType: "exact", Model: "glm-5.3"}, + }, finalModel: "glm-5.3", wantEffort: "high"}, + {name: "mapping deny is local", effort: "medium", mappings: []service.ReasoningEffortMapping{{From: "medium", To: "deny"}}, wantDenied: true}, + {name: "no ceiling keeps provider normalization", effort: "medium", wantEffort: "high"}, + } { + for _, field := range []string{"reasoning_effort", "reasoning"} { + t.Run(test.name+"/"+field, func(t *testing.T) { + model, finalModel, platform := test.model, test.finalModel, test.platform + if model == "" { + model = "glm-5.2" + } + if finalModel == "" { + finalModel = "glm-5.2" + } + if platform == "" { + platform = service.PlatformZhipu + } + effortJSON := fmt.Sprintf(`%q`, test.effort) + effortPath := field + if field == "reasoning" { + effortJSON = fmt.Sprintf(`{"effort":%q}`, test.effort) + effortPath = "reasoning.effort" + } + body := []byte(fmt.Sprintf(`{"model":%q,%q:%s,"messages":[{"role":"user","content":"hello"}]}`, model, field, effortJSON)) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + apiKey := &service.APIKey{Group: &service.Group{ + Platform: service.PlatformComposite, MaxReasoningEffort: test.ceiling, + MaxReasoningEffortOverLimit: test.overLimit, ReasoningEffortMappings: test.mappings, + }} + require.True(t, openAICompatibleTextTargetAllowed(ginContext, apiKey, model)) + upstream := &chatReasoningPolicyUpstream{} + cfg := &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{AllowInsecureHTTP: true}}} + gateway := service.NewOpenAIGatewayService(nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil) + account := &service.Account{ID: 101, Platform: platform, Type: service.AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "sk-test", "base_url": "http://upstream.example", "api_protocol": service.APIProtocolChatCompletions, + "model_mapping": map[string]any{model: finalModel}, + }} + forwardBody, _, err := applyOpenAIChatCompletionsReasoningEffortPolicyForRequest(ginContext, apiKey, body) + if err != nil { + respondOpenAIReasoningEffortPolicyError(ginContext, err, (&OpenAIGatewayHandler{}).errorResponse) + } else { + _, err = gateway.ForwardAsChatCompletions(ginContext.Request.Context(), ginContext, account, forwardBody, "", "") + } + if test.wantDenied { + require.True(t, service.IsReasoningEffortPolicyDenied(err), "error: %v", err) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + require.Empty(t, upstream.bodies) + return + } + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, upstream.bodies, 1) + require.Equal(t, finalModel, gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, test.wantEffort, gjson.GetBytes(upstream.bodies[0], effortPath).String()) + require.Equal(t, test.effort, *service.RequestedReasoningEffortFromContext(ginContext.Request.Context())) + }) + } + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 6943f519e615..ec69f8b07546 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -204,6 +204,15 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } + if cappedBody, changed, policyErr := applyFinalOpenAIReasoningEffortCeiling(ctx, upstreamBody, upstreamModel); policyErr != nil { + if IsReasoningEffortPolicyDenied(policyErr) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeChatCompletionsError(c, http.StatusForbidden, "permission_error", policyErr.Error()) + } + return nil, policyErr + } else if changed { + upstreamBody = cappedBody + } SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index dfe326e8475a..c55e90b37023 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -81,6 +81,18 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) + originalBody := body + if IsCNProvider(account.Platform) { + policyBody, _, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + if policyErr != nil { + if IsReasoningEffortPolicyDenied(policyErr) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } + return nil, policyErr + } + body = policyBody + } startTime := time.Now() @@ -298,12 +310,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } } - if account.Platform == PlatformOpenAI { - policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody) + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { + policyBody, changed, policyErr := applyMessagesConvertedReasoningEffortPolicy(ctx, account, responsesBody, upstreamModel) if policyErr != nil { if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } else { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) } return nil, policyErr } @@ -466,7 +480,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil { return nil, fmt.Errorf("agent identity task recovery failed: %w", err) } - return s.ForwardAsAnthropic(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel) + return s.ForwardAsAnthropic(markAgentIdentityTaskRecoveryTried(ctx), c, account, originalBody, promptCacheKey, defaultMappedModel) } if previousResponseID != "" && (isOpenAICompatPreviousResponseNotFound(resp.StatusCode, upstreamMsg, respBody) || isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody)) { if isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody) { @@ -479,7 +493,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( zap.String("previous_response_id", truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen)), zap.String("upstream_model", upstreamModel), ) - return s.ForwardAsAnthropic(ctx, c, account, body, promptCacheKey, defaultMappedModel) + return s.ForwardAsAnthropic(ctx, c, account, originalBody, promptCacheKey, defaultMappedModel) } // Grok account-switched history often fails decrypt; strip encrypted // reasoning once at the client-body level so failover accounts can accept @@ -487,7 +501,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if account.Platform == PlatformGrok && isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) && !grokEncryptedContentStripRetried(ctx) { - if strippedBody, ok := stripAnthropicThinkingSignatures(body); ok { + if strippedBody, ok := stripAnthropicThinkingSignatures(originalBody); ok { logger.L().Info("openai messages: stripping thinking signatures for Grok failover retry", zap.Int64("account_id", account.ID), ) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 12a334406897..65864a0904ad 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -47,6 +47,21 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( return nil, fmt.Errorf("missing model in request") } clientStream := gjson.GetBytes(body, "stream").Bool() + requestedReasoningEffort := RequestedReasoningEffortFromContext(ctx) + if requestedReasoningEffort == nil { + requestedReasoningEffort = NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()) + } + if IsCNProvider(account.Platform) { + policyBody, _, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + if policyErr != nil { + if IsReasoningEffortPolicyDenied(policyErr) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } + return nil, policyErr + } + body = policyBody + } billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) @@ -59,14 +74,36 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( } if normalized, changed := NormalizeGLM53AnthropicThinking(body, upstreamModel); changed { body = normalized + if ctx != nil { + if policy, bound := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy); bound && IsCNProvider(account.Platform) { + // Final pass is cap-only: nil mappings avoid remapping the original client effort already mapped above. + capped, _, policyErr := ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, nil, policy.overLimit) + if policyErr == nil { + // low/high/max are the existing NormalizeGLM53AnthropicThinking contract. + switch effort := gjson.GetBytes(capped, "output_config.effort").String(); effort { + case "low", "high", "max": + body = capped + default: + policyErr = fmt.Errorf("GLM-5.3 does not support capped reasoning effort %q (supported: low, high, max): %w", effort, + &ReasoningEffortOverLimitError{Requested: gjson.GetBytes(body, "output_config.effort").String(), Max: NormalizeMaxReasoningEffort(policy.maxEffort)}) + } + } + if policyErr != nil { + if IsReasoningEffortPolicyDenied(policyErr) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } + return nil, policyErr + } + } + } } // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high // (对齐 Anthropic 网关 gateway_handler 的记录语义,避免该路径长期落 NULL)。 - requestedReasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()) reasoningEffort := ApplyThinkingEnabledFallback( - requestedReasoningEffort, + NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()), body, billingModel, ) @@ -116,10 +153,16 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( return s.handleAnthropicErrorResponse(resp, c, account, billingModel) } + var result *OpenAIForwardResult if clientStream { - return s.handleNativeAnthropicStreamingResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) + result, err = s.handleNativeAnthropicStreamingResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) + } else { + result, err = s.handleNativeAnthropicBufferedResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) + } + if result != nil { + result.RequestedReasoningEffort = requestedReasoningEffort } - return s.handleNativeAnthropicBufferedResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) + return result, err } // nativeAnthropicTargetURL 组装国产供应商原生 Anthropic messages 端点。 diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go index bea8ab1cef24..54863b2d8f17 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "strings" "testing" @@ -44,6 +45,264 @@ func nativeAnthropicGLMTestAccount() *Account { return account } +func TestNativeAnthropicBoundReasoningEffortPolicy(testContext *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, test := range []struct { + name string + platform string + maxEffort string + overLimit string + to string + bound bool + omitEffort bool + wantEffort string + requested string + deny bool + }{ + {name: "deepseek low to max", platform: PlatformDeepseek, to: "max", bound: true, wantEffort: "max"}, + {name: "kimi low to max", platform: PlatformKimi, to: "max", bound: true, wantEffort: "max"}, + {name: "zhipu low to max", platform: PlatformZhipu, to: "max", bound: true, wantEffort: "max"}, + {name: "minimax low to max", platform: PlatformMiniMax, to: "max", bound: true, wantEffort: "max"}, + {name: "mapped ceiling", platform: PlatformDeepseek, to: "max", maxEffort: "high", bound: true, wantEffort: "high"}, + {name: "mapping deny", platform: PlatformDeepseek, to: ReasoningEffortMappingDeny, bound: true, deny: true}, + {name: "ceiling deny", platform: PlatformDeepseek, to: "max", maxEffort: "high", overLimit: ReasoningEffortOverLimitDeny, bound: true, deny: true}, + {name: "unbound", platform: PlatformDeepseek, wantEffort: "low"}, + {name: "empty policy", platform: PlatformDeepseek, bound: true, wantEffort: "low"}, + {name: "original context effort", platform: PlatformDeepseek, to: "max", bound: true, requested: "minimal", wantEffort: "max"}, + {name: "non CN", platform: PlatformOpenCodeGo, to: "max", bound: true, wantEffort: "low"}, + {name: "no synthesis", platform: PlatformDeepseek, to: "max", maxEffort: "low", bound: true, omitEffort: true}, + } { + testContext.Run(fmt.Sprintf("%s/stream=%v", test.name, stream), func(testContext *testing.T) { + effortJSON := `,"output_config":{"effort":"low"}` + if test.omitEffort { + effortJSON = "" + } + body := []byte(fmt.Sprintf(`{"model":"deepseek-flash","max_tokens":32,"stream":%v,"messages":[{"role":"user","content":"hi"}]%s}`, stream, effortJSON)) + ctx := context.Background() + if test.requested != "" { + ctx = WithRequestedReasoningEffort(ctx, test.requested) + } + if test.bound { + var mappings []ReasoningEffortMapping + if test.to != "" { + mappings = []ReasoningEffortMapping{{From: "low", To: test.to, MatchType: "exact", Model: "deepseek-flash"}} + } + ctx = WithOpenAIReasoningEffortPolicy(ctx, test.maxEffort, mappings, test.overLimit) + } + response := nativeAnthropicBufferedResponse() + if stream { + response = nativeAnthropicStreamResponse() + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := nativeAnthropicTestAccount() + account.Platform = test.platform + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(string(body))).WithContext(ctx) + result, err := svc.ForwardAsAnthropic(ctx, ginContext, account, body, "", "upstream-rewritten-model") + if test.deny { + require.Error(testContext, err) + require.True(testContext, IsReasoningEffortPolicyDenied(err)) + require.Nil(testContext, result) + require.Empty(testContext, upstream.requests) + require.Equal(testContext, http.StatusForbidden, ginContext.Writer.Status()) + require.Equal(testContext, "error", gjson.GetBytes(recorder.Body.Bytes(), "type").String()) + require.Equal(testContext, "forbidden_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + return + } + require.NoError(testContext, err) + require.NotNil(testContext, result) + require.Equal(testContext, stream, result.Stream) + require.Equal(testContext, "upstream-rewritten-model", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(testContext, test.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + if test.omitEffort { + require.Nil(testContext, result.RequestedReasoningEffort) + require.Nil(testContext, result.ReasoningEffort) + } else { + wantRequested := test.requested + if wantRequested == "" { + wantRequested = "low" + } + require.NotNil(testContext, result.RequestedReasoningEffort) + require.NotNil(testContext, result.ReasoningEffort) + require.Equal(testContext, wantRequested, *result.RequestedReasoningEffort) + require.Equal(testContext, test.wantEffort, *result.ReasoningEffort) + } + }) + } + } +} + +func TestNativeAnthropicGLM53OutboundReasoningEffortPolicy(testContext *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, test := range []struct { + name string + effort string + thinking string + maxEffort string + overLimit string + mappings []ReasoningEffortMapping + unbound bool + requested string + wantEffort string + denyRequested string + denyMax string + errorContains string + }{ + {name: "max capped to high", effort: "max", maxEffort: "high", wantEffort: "high"}, + {name: "max capped to low", effort: "max", maxEffort: "low", wantEffort: "low"}, + {name: "max cap", effort: "max", maxEffort: "max", wantEffort: "max"}, + {name: "max capped to unsupported medium", effort: "max", maxEffort: "medium", denyRequested: "high", denyMax: "medium", errorContains: `does not support capped reasoning effort "medium"`}, + {name: "normalized medium exceeds medium cap", effort: "medium", maxEffort: "medium", denyRequested: "high", denyMax: "medium", errorContains: `does not support capped reasoning effort "medium"`}, + {name: "xhigh cap incompatible with max", effort: "max", maxEffort: "xhigh", denyRequested: "max", denyMax: "xhigh", errorContains: `does not support capped reasoning effort "xhigh"`}, + {name: "minimal cap incompatible with low", effort: "low", maxEffort: "minimal", denyRequested: "low", denyMax: "minimal", errorContains: `does not support capped reasoning effort "minimal"`}, + { + name: "original medium mapped to low", effort: "medium", maxEffort: "medium", wantEffort: "low", + mappings: []ReasoningEffortMapping{{From: "medium", To: "low", MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "original medium mapped to low before incompatible cap", effort: "medium", maxEffort: "minimal", denyRequested: "low", denyMax: "minimal", + mappings: []ReasoningEffortMapping{{From: "medium", To: "low", MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "original medium mapping deny", effort: "medium", denyRequested: "medium", + mappings: []ReasoningEffortMapping{{From: "medium", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "mapping capped to high", effort: "low", maxEffort: "high", wantEffort: "high", + mappings: []ReasoningEffortMapping{{From: "low", To: "max", MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "chained mappings single hop", effort: "low", wantEffort: "high", + mappings: []ReasoningEffortMapping{ + {From: "low", To: "medium", MatchType: "exact", Model: "public-glm"}, + {From: "medium", To: "max", MatchType: "exact", Model: "public-glm"}, + }, + }, + { + name: "mapped value not denied a second time", effort: "low", wantEffort: "high", + mappings: []ReasoningEffortMapping{ + {From: "low", To: "high", MatchType: "exact", Model: "public-glm"}, + {From: "high", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-glm"}, + }, + }, + { + name: "normalized value not mapped again", effort: "medium", maxEffort: "high", wantEffort: "high", + mappings: []ReasoningEffortMapping{{From: "high", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "mapping deny", effort: "low", denyRequested: "low", + mappings: []ReasoningEffortMapping{{From: "low", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-glm"}}, + }, + { + name: "mapping ceiling deny", effort: "low", maxEffort: "high", overLimit: ReasoningEffortOverLimitDeny, denyRequested: "max", denyMax: "high", + mappings: []ReasoningEffortMapping{{From: "low", To: "max", MatchType: "exact", Model: "public-glm"}}, + }, + {name: "ceiling deny", effort: "max", maxEffort: "medium", overLimit: ReasoningEffortOverLimitDeny, denyRequested: "max", denyMax: "medium"}, + {name: "normalized ceiling deny", effort: "medium", maxEffort: "medium", overLimit: ReasoningEffortOverLimitDeny, denyRequested: "high", denyMax: "medium"}, + {name: "normalized xhigh ceiling deny", effort: "xhigh", maxEffort: "xhigh", overLimit: ReasoningEffortOverLimitDeny, denyRequested: "max", denyMax: "xhigh"}, + {name: "low remains supported below medium cap", effort: "low", maxEffort: "medium", wantEffort: "low"}, + {name: "unbound retains normalization", effort: "medium", unbound: true, wantEffort: "high"}, + {name: "empty policy retains normalization", effort: "medium", wantEffort: "high"}, + {name: "requested context preserved", effort: "max", requested: "minimal", maxEffort: "high", wantEffort: "high"}, + {name: "unspecified effort not synthesized", maxEffort: "medium"}, + {name: "unspecified effort not denied", maxEffort: "low", overLimit: ReasoningEffortOverLimitDeny}, + { + name: "disabled thinking not mapped", thinking: "disabled", maxEffort: "medium", wantEffort: "low", + mappings: []ReasoningEffortMapping{{From: "low", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-glm"}}, + }, + {name: "disabled thinking unbound", thinking: "disabled", unbound: true, wantEffort: "low"}, + {name: "disabled thinking incompatible cap", thinking: "disabled", maxEffort: "minimal", denyRequested: "low", denyMax: "minimal"}, + {name: "explicit medium overrides disabled thinking", thinking: "disabled", effort: "medium", wantEffort: "high"}, + } { + testContext.Run(fmt.Sprintf("%s/stream=%v", test.name, stream), func(testContext *testing.T) { + effortJSON := "" + if test.effort != "" { + effortJSON = fmt.Sprintf(`,"output_config":{"effort":%q}`, test.effort) + } + if test.thinking != "" { + effortJSON += fmt.Sprintf(`,"thinking":{"type":%q}`, test.thinking) + } + body := []byte(fmt.Sprintf(`{"model":"public-glm","max_tokens":32,"stream":%v,"messages":[{"role":"user","content":"hi"}]%s}`, stream, effortJSON)) + ctx := context.Background() + if test.requested != "" { + ctx = WithRequestedReasoningEffort(ctx, test.requested) + } + if !test.unbound { + ctx = WithOpenAIReasoningEffortPolicy(ctx, test.maxEffort, test.mappings, test.overLimit) + } + response := nativeAnthropicBufferedResponse() + if stream { + response = nativeAnthropicStreamResponse() + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(string(body))).WithContext(ctx) + result, err := svc.ForwardAsAnthropic(ctx, ginContext, nativeAnthropicGLMTestAccount(), body, "", "glm-5.3") + if test.denyRequested != "" { + require.Error(testContext, err) + require.True(testContext, IsReasoningEffortPolicyDenied(err)) + if test.denyMax != "" { + var denied *ReasoningEffortOverLimitError + require.ErrorAs(testContext, err, &denied) + require.Equal(testContext, test.denyRequested, denied.Requested) + require.Equal(testContext, test.denyMax, denied.Max) + } else { + var denied *ReasoningEffortMappingDeniedError + require.ErrorAs(testContext, err, &denied) + require.Equal(testContext, test.denyRequested, denied.Requested) + } + if test.errorContains != "" { + require.ErrorContains(testContext, err, test.errorContains) + } + require.Nil(testContext, result) + require.Empty(testContext, upstream.requests) + require.Equal(testContext, http.StatusForbidden, recorder.Code) + require.Equal(testContext, "forbidden_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + require.Equal(testContext, err.Error(), gjson.GetBytes(recorder.Body.Bytes(), "error.message").String()) + return + } + require.NoError(testContext, err) + require.NotNil(testContext, result) + require.Len(testContext, upstream.requests, 1) + require.Equal(testContext, http.MethodPost, upstream.lastReq.Method) + require.Equal(testContext, "/api/anthropic/v1/messages", upstream.lastReq.URL.Path) + require.Equal(testContext, "glm-5.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(testContext, stream, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.Equal(testContext, test.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.Equal(testContext, stream, result.Stream) + require.Equal(testContext, "public-glm", result.Model) + require.Equal(testContext, "glm-5.3", result.UpstreamModel) + if test.effort == "" && test.thinking == "" { + require.False(testContext, gjson.GetBytes(upstream.lastBody, "output_config.effort").Exists()) + require.False(testContext, gjson.GetBytes(upstream.lastBody, "thinking").Exists()) + require.Nil(testContext, result.RequestedReasoningEffort) + require.Nil(testContext, result.ReasoningEffort) + return + } + require.Equal(testContext, "enabled", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + wantRequested := test.effort + if test.requested != "" { + wantRequested = test.requested + } + require.NotNil(testContext, result.ReasoningEffort) + if wantRequested == "" { + require.Nil(testContext, result.RequestedReasoningEffort) + } else { + require.NotNil(testContext, result.RequestedReasoningEffort) + require.Equal(testContext, wantRequested, *result.RequestedReasoningEffort) + } + require.Equal(testContext, test.wantEffort, *result.ReasoningEffort) + }) + } + } +} + func nativeAnthropicBufferedResponse() *http.Response { return &http.Response{ StatusCode: http.StatusOK, diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 59a70dd191e8..b2dcfee67cb4 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -36,6 +36,17 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( defaultMappedModel string, ) (*OpenAIForwardResult, error) { startTime := time.Now() + if IsCNProvider(account.Platform) { + policyBody, _, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + if policyErr != nil { + if IsReasoningEffortPolicyDenied(policyErr) { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } + return nil, policyErr + } + body = policyBody + } // 1. Parse Anthropic request var anthropicReq apicompat.AnthropicRequest @@ -85,15 +96,19 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if err != nil { return nil, fmt.Errorf("marshal chat completions request: %w", err) } - if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { - chatBody = normalizedBody + if !IsCNProvider(account.Platform) { + if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { + chatBody = normalizedBody + } } - if account.Platform == PlatformOpenAI { - policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody) + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { + policyBody, changed, policyErr := applyMessagesConvertedReasoningEffortPolicy(ctx, account, chatBody, upstreamModel) if policyErr != nil { if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) + } else { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) } return nil, policyErr } @@ -101,6 +116,11 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatBody = policyBody } } + if IsCNProvider(account.Platform) { + if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { + chatBody = normalizedBody + } + } // 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_messages_cn_policy_test.go b/backend/internal/service/openai_gateway_messages_cn_policy_test.go new file mode 100644 index 000000000000..b8a383011a44 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_cn_policy_test.go @@ -0,0 +1,268 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestForwardAsAnthropic_ConvertedCNReasoningPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { + for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses} { + forwardedProtocol := protocol + if platform == PlatformZhipu && protocol == APIProtocolResponses { + forwardedProtocol = APIProtocolChatCompletions + } + for _, stream := range []bool{false, true} { + for _, test := range []struct { + name string + effort string + maxEffort string + overLimit string + mappings []ReasoningEffortMapping + unbound bool + wantEffort string + wantStatus int + }{ + {name: "mapping", mappings: []ReasoningEffortMapping{{From: "low", To: "max"}}, wantEffort: "max"}, + {name: "mapping once", mappings: []ReasoningEffortMapping{{From: "low", To: "high"}, {From: "high", To: "max"}}, wantEffort: "high"}, + {name: "mapped value not denied twice", mappings: []ReasoningEffortMapping{{From: "low", To: "high"}, {From: "high", To: ReasoningEffortMappingDeny}}, wantEffort: "high"}, + {name: "original max mapping", effort: "max", mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: "exact", Model: "public-alias"}}, wantEffort: "low"}, + {name: "mapping cap", maxEffort: "medium", mappings: []ReasoningEffortMapping{{From: "low", To: "max"}}, wantEffort: "medium"}, + {name: "ceiling filter", effort: "high", maxEffort: "medium", wantEffort: "medium"}, + {name: "ceiling only deny", effort: "high", maxEffort: "medium", overLimit: ReasoningEffortOverLimitDeny, wantStatus: http.StatusForbidden}, + {name: "mapping deny", mappings: []ReasoningEffortMapping{{From: "low", To: ReasoningEffortMappingDeny}}, wantStatus: http.StatusForbidden}, + {name: "ceiling deny", maxEffort: "high", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "low", To: "max"}}, wantStatus: http.StatusForbidden}, + {name: "public model match", mappings: []ReasoningEffortMapping{{From: "low", To: "max", MatchType: "exact", Model: "public-alias"}}, wantEffort: "max"}, + {name: "public model deny", mappings: []ReasoningEffortMapping{{From: "low", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "public-alias"}}, wantStatus: http.StatusForbidden}, + {name: "final model does not remap", mappings: []ReasoningEffortMapping{{From: "low", To: ReasoningEffortMappingDeny, MatchType: "exact", Model: "deepseek-flash"}}, wantEffort: "low"}, + {name: "unbound", unbound: true, wantEffort: "low"}, + {name: "empty policy", wantEffort: "low"}, + } { + t.Run(fmt.Sprintf("%s/%s/stream=%v/%s", platform, protocol, stream, test.name), func(t *testing.T) { + ctx := context.Background() + if !test.unbound { + ctx = WithOpenAIReasoningEffortPolicy(ctx, test.maxEffort, test.mappings, test.overLimit) + } + effort := test.effort + if effort == "" { + effort = "low" + } + result, upstream, recorder := forwardConvertedMessagesPolicy(t, ctx, platform, protocol, "deepseek-flash", effort, stream) + if test.wantStatus != 0 { + require.Nil(t, result) + require.Empty(t, upstream.requests) + require.Equal(t, test.wantStatus, recorder.Code) + require.Equal(t, "forbidden_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + return + } + assertConvertedMessagesPolicyForward(t, result, upstream, forwardedProtocol, "deepseek-flash", test.wantEffort) + }) + } + } + } + } +} + +func TestForwardAsAnthropic_ConvertedPolicyFinalModelCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses} { + for _, test := range []struct { + name string + platform string + model string + effort string + to string + maxEffort string + unbound bool + wantEffort string + wantChatEffort string + wantStatus int + }{ + {name: "mapped GPT61 none rejected", platform: PlatformKimi, model: "gpt-6.1-sol", effort: "high", to: "none", wantStatus: http.StatusBadRequest}, + {name: "mapped GPT61 minimal cap rejected", platform: PlatformKimi, model: "gpt-6.1-sol", effort: "high", maxEffort: "minimal", wantStatus: http.StatusBadRequest}, + {name: "OpenAI GPT61 final validation", platform: PlatformOpenAI, model: "gpt-6.1-sol", effort: "high", to: "none", wantStatus: http.StatusBadRequest}, + {name: "OpenAI GPT61 minimal ceiling rejected", platform: PlatformOpenAI, model: "gpt-6.1-sol", effort: "high", maxEffort: "minimal", wantStatus: http.StatusBadRequest}, + {name: "mapped GPT61 valid", platform: PlatformKimi, model: "gpt-6.1-sol", effort: "high", to: "low", wantEffort: "low"}, + {name: "other final model allows none", platform: PlatformKimi, model: "gpt-5.5", effort: "high", to: "none", wantEffort: "none"}, + {name: "GLM53 low cap", platform: PlatformZhipu, model: "glm-5.3", effort: "high", maxEffort: "low", wantEffort: "low"}, + {name: "GLM53 medium cap rejected", platform: PlatformZhipu, model: "glm-5.3", effort: "high", maxEffort: "medium", wantStatus: http.StatusForbidden}, + {name: "GLM older low cap rejected", platform: PlatformZhipu, model: "glm-4.7", effort: "high", maxEffort: "low", wantStatus: http.StatusForbidden}, + {name: "GLM deny before native normalization", platform: PlatformZhipu, model: "glm-4.7", effort: "low", to: ReasoningEffortMappingDeny, wantStatus: http.StatusForbidden}, + {name: "GLM native max", platform: PlatformZhipu, model: "glm-5.3", effort: "high", to: "xhigh", wantEffort: "max"}, + {name: "GLM native mapping capped", platform: PlatformZhipu, model: "glm-5.3", effort: "high", to: "xhigh", maxEffort: "high", wantEffort: "high"}, + {name: "GLM mapped once after normalization", platform: PlatformZhipu, model: "glm-5.3", effort: "high", to: "medium", wantEffort: "high"}, + {name: "non CN policy unchanged", platform: PlatformOpenCodeGo, model: "cn-final", effort: "high", to: ReasoningEffortMappingDeny, wantEffort: "high"}, + {name: "OpenAI existing cap", platform: PlatformOpenAI, model: "gpt-5.5", effort: "high", maxEffort: "medium", wantEffort: "medium"}, + {name: "GLM unbound unchanged", platform: PlatformZhipu, model: "glm-5.3", effort: "high", unbound: true, wantEffort: "high"}, + {name: "GLM unbound normalization unchanged", platform: PlatformZhipu, model: "glm-5.3", effort: "medium", unbound: true, wantEffort: "medium", wantChatEffort: "high"}, + {name: "GLM empty policy normalization unchanged", platform: PlatformZhipu, model: "glm-5.3", effort: "medium", wantEffort: "medium", wantChatEffort: "high"}, + {name: "omitted effort default unchanged", platform: PlatformDeepseek, model: "deepseek-flash", unbound: true, wantEffort: "medium"}, + } { + t.Run(protocol+"/"+test.name, func(t *testing.T) { + platform := test.platform + if platform == PlatformZhipu && protocol == APIProtocolResponses { + platform = PlatformKimi + } + ctx := context.Background() + if !test.unbound { + var mappings []ReasoningEffortMapping + if test.to != "" { + mappings = []ReasoningEffortMapping{{From: test.effort, To: test.to}} + } + ctx = WithOpenAIReasoningEffortPolicy(ctx, test.maxEffort, mappings, "") + } + result, upstream, recorder := forwardConvertedMessagesPolicy(t, ctx, platform, protocol, test.model, test.effort, false) + if test.wantStatus != 0 { + require.Nil(t, result) + require.Empty(t, upstream.requests) + require.Equal(t, test.wantStatus, recorder.Code) + if test.wantStatus == http.StatusBadRequest { + require.Equal(t, "invalid_request_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + require.Contains(t, gjson.GetBytes(recorder.Body.Bytes(), "error.message").String(), "gpt-6.1-sol does not support reasoning effort") + } else { + require.Equal(t, "forbidden_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + } + return + } + wantEffort := test.wantEffort + if protocol == APIProtocolChatCompletions && test.wantChatEffort != "" { + wantEffort = test.wantChatEffort + } + assertConvertedMessagesPolicyForward(t, result, upstream, protocol, test.model, wantEffort) + }) + } + } +} + +func TestForwardAsAnthropic_CNResponsesContinuationReasoningPolicyOnce(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, platform := range []string{PlatformKimi, PlatformDeepseek, PlatformMiniMax} { + for _, recovery := range []struct { + name string + message string + }{ + {name: "missing", message: "previous response not found"}, + {name: "unsupported", message: "previous_response_id is only supported on Responses WebSocket v2"}, + } { + for _, nextEffort := range []string{"max", ReasoningEffortMappingDeny} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/high_to_%s/stream=%v", platform, recovery.name, nextEffort, stream), func(t *testing.T) { + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "", []ReasoningEffortMapping{ + {From: "low", To: "high", MatchType: "exact", Model: "public-alias"}, + {From: "high", To: nextEffort, MatchType: "exact", Model: "public-alias"}, + }, "") + account := rawGPT56ResponsesAPIKeyAccount("public-alias", "gpt-5.3-codex") + account.Platform = platform + account.Credentials["api_protocol"] = APIProtocolResponses + account.Credentials["api_base_urls"] = map[string]any{APIProtocolResponses: "http://upstream.example"} + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"error":{"message":%q,"type":"invalid_request_error"}}`, recovery.message))), + }, + openAICompatSSECompletedResponse("resp_recovered", "gpt-5.3-codex"), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + svc.bindOpenAICompatSessionResponseID(ctx, nil, account, "policy-session", "resp_missing") + body := []byte(fmt.Sprintf(`{"model":"public-alias","max_tokens":32,"output_config":{"effort":"low"},"stream":%v,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}]}`, stream)) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(string(body))).WithContext(ctx) + + result, err := svc.ForwardAsAnthropic(ctx, ginContext, account, body, "policy-session", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, stream, result.Stream) + require.Len(t, upstream.requests, 2) + for requestIndex, sent := range upstream.bodies { + require.True(t, strings.HasSuffix(upstream.requests[requestIndex].URL.Path, "/responses")) + require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(sent, "model").String()) + require.Equal(t, "high", gjson.GetBytes(sent, "reasoning.effort").String(), "request %d", requestIndex) + } + require.Equal(t, "second", gjson.GetBytes(upstream.bodies[0], `input.#(role=="user").content.0.text`).String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) + require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], `input.#(role=="user").content.0.text`).String()) + require.Contains(t, string(upstream.bodies[1]), "first") + require.Contains(t, string(upstream.bodies[1]), "second") + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "high", *result.ReasoningEffort) + require.Equal(t, "low", gjson.GetBytes(body, "output_config.effort").String()) + require.Equal(t, recovery.name == "unsupported", svc.isOpenAICompatSessionContinuationDisabled(ctx, ginContext, account, "policy-session")) + }) + } + } + } + } +} + +func forwardConvertedMessagesPolicy(t *testing.T, ctx context.Context, platform, protocol, model, effort string, stream bool) (*OpenAIForwardResult, *httpUpstreamRecorder, *httptest.ResponseRecorder) { + t.Helper() + effortJSON := "" + if effort != "" { + effortJSON = fmt.Sprintf(`,"output_config":{"effort":%q}`, effort) + } + body := []byte(fmt.Sprintf(`{"model":"public-alias","max_tokens":32,"messages":[{"role":"user","content":"hi"}],"stream":%v%s}`, stream, effortJSON)) + account := rawChatCompletionsTestAccount() + if platform == PlatformOpenAI && protocol == APIProtocolChatCompletions { + account = forceChatMessagesFallbackAccount() + } + account.Platform = platform + account.Credentials["api_protocol"] = protocol + account.Credentials["api_base_urls"] = map[string]any{protocol: "http://upstream.example"} + account.Credentials["model_mapping"] = map[string]any{"public-alias": model} + if platform == PlatformZhipu && protocol == APIProtocolResponses { + protocol = APIProtocolChatCompletions + } + responseBody := `{"id":"chatcmpl_policy","model":"` + model + `","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}` + contentType := "application/json" + if protocol == APIProtocolResponses { + contentType = "text/event-stream" + responseBody = "data: " + `{"type":"response.completed","response":{"id":"resp_policy","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_policy","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":3,"output_tokens":2}}}` + "\n\ndata: [DONE]\n\n" + } else if stream { + contentType = "text/event-stream" + responseBody = "data: " + `{"id":"chatcmpl_policy","model":"` + model + `","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":null}]}` + "\n\ndata: " + `{"id":"chatcmpl_policy","model":"` + model + `","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}` + "\n\ndata: [DONE]\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(responseBody))}} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(string(body))).WithContext(ctx) + result, err := svc.ForwardAsAnthropic(ctx, ginContext, account, body, "", "") + if err != nil { + require.Nil(t, result) + require.Contains(t, []int{http.StatusBadRequest, http.StatusForbidden}, recorder.Code, "unexpected forwarding error: %v", err) + } else { + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, stream, result.Stream) + } + return result, upstream, recorder +} + +func assertConvertedMessagesPolicyForward(t *testing.T, result *OpenAIForwardResult, upstream *httpUpstreamRecorder, protocol, model, effort string) { + t.Helper() + require.NotNil(t, result) + require.Len(t, upstream.requests, 1) + path, effortPath := "/chat/completions", "reasoning_effort" + if protocol == APIProtocolResponses { + path, effortPath = "/responses", "reasoning.effort" + } + require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, path), upstream.lastReq.URL.String()) + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, effort, gjson.GetBytes(upstream.lastBody, effortPath).String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, effort, *result.ReasoningEffort) +} diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 907025597b4a..7a8bdea593e0 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -431,7 +431,7 @@ func RequestedReasoningEffortFromContext(ctx context.Context) *string { } // WithOpenAIReasoningEffortPolicy binds a group policy to a request after its -// concrete target platform has been resolved to OpenAI. The policy is copied so +// concrete OpenAI-compatible target platform has been resolved. The policy is copied so // retries and asynchronous forwarding cannot observe later slice mutations. func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) context.Context { if ctx == nil { @@ -458,6 +458,68 @@ func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byt return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit) } +func applyFinalOpenAIReasoningEffortCeiling(ctx context.Context, body []byte, upstreamModel string) ([]byte, bool, error) { + if ctx == nil { + return body, false, nil + } + policy, bound := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy) + if !bound || (policy.maxEffort == "" && len(policy.mappings) == 0) { + return body, false, nil + } + capped, changed, err := ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, nil, policy.overLimit) + if err != nil { + return body, false, err + } + glmModel := strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstreamModel)), "glm-") + for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + effort := gjson.GetBytes(capped, path).String() + if glmModel && effort != "" && effort != "high" && effort != "max" && (!isGLM53Model(upstreamModel) || effort != "low") { + return body, false, fmt.Errorf("%s does not support capped reasoning effort %q: %w", upstreamModel, effort, + &ReasoningEffortOverLimitError{Requested: gjson.GetBytes(body, path).String(), Max: NormalizeMaxReasoningEffort(policy.maxEffort)}) + } + } + return capped, changed, nil +} + +// applyMessagesConvertedReasoningEffortPolicy validates the final outbound effort. +// CN mappings already ran on the original Messages model/effort before conversion; +// only recheck the ceiling here, since conversion/provider normalization may raise +// the effort. Reapplying mappings would chain rules or match the mapped model. +// OpenAI keeps its existing post-conversion mapping semantics. +func applyMessagesConvertedReasoningEffortPolicy(ctx context.Context, account *Account, body []byte, upstreamModel string) ([]byte, bool, error) { + if account.Platform != PlatformOpenAI && !IsCNProvider(account.Platform) { + return body, false, nil + } + updated, changed := body, false + if account.Platform == PlatformOpenAI { + var err error + updated, changed, err = ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + if err != nil { + return body, false, err + } + } + if ctx != nil && IsCNProvider(account.Platform) { + policy, bound := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy) + if bound && (policy.maxEffort != "" || len(policy.mappings) > 0) { + glmModel := strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstreamModel)), "glm-") + if glmModel { + if normalized, normalizedChanged := NormalizeGLMOpenAIReasoningEffort(updated, upstreamModel); normalizedChanged { + updated, changed = normalized, true + } + } + capped, cappedChanged, capErr := applyFinalOpenAIReasoningEffortCeiling(ctx, updated, upstreamModel) + if capErr != nil { + return body, false, capErr + } + updated, changed = capped, changed || cappedChanged + } + } + if err := validateGPT61SolCompatRequest(updated, upstreamModel); err != nil { + return body, false, err + } + return updated, changed, nil +} + func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) { value := strings.TrimSpace(raw) canonical := normalizeReasoningEffortMappingSource(value) diff --git a/frontend/package.json b/frontend/package.json index ef38431a73f9..e42bcb03f8d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "postcss@<8.5.18": ">=8.5.18", "dompurify@<3.4.17": ">=3.4.17", "source-map-js@<1.2.2": ">=1.2.2", - "follow-redirects@<1.16.1": ">=1.16.1", + "follow-redirects@<1.16.2": ">=1.16.2", "decode-uri-component@<0.5.1": ">=0.5.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a6f4b6042526..1bf73beac904 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,7 +10,7 @@ overrides: postcss@<8.5.18: '>=8.5.18' dompurify@<3.4.17: '>=3.4.17' source-map-js@<1.2.2: '>=1.2.2' - follow-redirects@<1.16.1: '>=1.16.1' + follow-redirects@<1.16.2: '>=1.16.2' decode-uri-component@<0.5.1: '>=0.5.1' importers: @@ -2566,8 +2566,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -6563,7 +6563,7 @@ snapshots: axios@1.20.0: dependencies: - follow-redirects: 1.16.1 + follow-redirects: 1.16.2 form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -7305,7 +7305,7 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.16.1: {} + follow-redirects@1.16.2: {} for-in@1.0.2: {}