From 2a8cb088af205586b514a27f6a63e9bcf7adb4e6 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 15 Oct 2026 01:56:00 +0800 Subject: [PATCH] feat(apicompat): configure minimum output token floor --- backend/internal/config/config.go | 7 +++ backend/internal/config/config_test.go | 23 ++++++++ .../pkg/apicompat/anthropic_to_responses.go | 10 +++- .../chatcompletions_anthropic_bridge.go | 10 +++- .../chatcompletions_anthropic_bridge_test.go | 37 ++++++++++++ .../chatcompletions_responses_test.go | 56 +++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 10 +++- backend/internal/pkg/apicompat/types.go | 9 ++- .../service/antigravity_gateway_compat.go | 2 +- backend/internal/service/apicompat_config.go | 28 ++++++++++ .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- .../gemini_chat_completions_compat_service.go | 2 +- .../openai_gateway_chat_completions.go | 2 +- ...teway_chat_completions_anthropic_native.go | 2 +- .../service/openai_gateway_forward.go | 2 +- .../openai_gateway_grok_chat_bridge.go | 2 +- .../service/openai_gateway_messages.go | 2 +- .../openai_gateway_messages_chat_fallback.go | 5 +- .../service/openai_gateway_passthrough.go | 1 + .../service/openai_gateway_request_body.go | 8 ++- .../openai_responses_ingress_compat.go | 11 +++- .../service/openai_ws_forwarder_ingress.go | 2 +- .../openai_ws_v2_passthrough_adapter.go | 4 +- deploy/config.example.yaml | 3 + 25 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 backend/internal/service/apicompat_config.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e99b547ec8..1f488c89af 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -979,6 +979,9 @@ const ( // GatewayConfig API网关相关配置 type GatewayConfig struct { + // MinOutputTokens is the minimum max_output_tokens used when + // converting Chat Completions or Anthropic requests to Responses. + MinOutputTokens int `mapstructure:"min_output_tokens"` // 等待上游响应头的超时时间(秒),0表示无超时 // 注意:这不影响流式数据传输,只控制等待响应头的时间 ResponseHeaderTimeout int `mapstructure:"response_header_timeout"` @@ -2035,6 +2038,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) { func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) + viper.SetDefault("gateway.min_output_tokens", 128) viper.SetDefault("simple_mode.auto_create_default_groups", true) viper.SetDefault("simple_mode_key_rate_limit_enabled", false) @@ -2707,6 +2711,9 @@ func setEnvReachableDefaults() { } func (c *Config) Validate() error { + if c.Gateway.MinOutputTokens < 16 { + return fmt.Errorf("gateway.min_output_tokens must be at least 16") + } forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders) if err != nil { return fmt.Errorf("security.forwarded_client_ip_headers: %w", err) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 96cac60f43..434b889955 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -31,6 +31,29 @@ func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) } +func TestLoadDefaultMinOutputTokens(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 128, cfg.Gateway.MinOutputTokens) +} + +func TestLoadMinOutputTokensFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_MIN_OUTPUT_TOKENS", "16") + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 16, cfg.Gateway.MinOutputTokens) +} + +func TestValidateMinOutputTokensMinimum(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + cfg.Gateway.MinOutputTokens = 15 + require.EqualError(t, cfg.Validate(), "gateway.min_output_tokens must be at least 16") +} + func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index bb46a5e48e..a54ca94133 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -13,6 +13,12 @@ import ( // Chat Completions intermediary round-trip (e.g. thinking, cache_control, // structured system prompts). func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { + return AnthropicToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// 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 } @@ -50,8 +56,8 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index fc73afa3a4..20a590c1c6 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -40,6 +40,12 @@ import ( // ResponsesToChatCompletionsRequest but avoids materializing the intermediate // ResponsesRequest and the extra marshal/unmarshal cycle. func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsRequest, error) { + return AnthropicToChatCompletionsRequestWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// AnthropicToChatCompletionsRequestWithMinOutputTokens converts an Anthropic +// Messages request using the supplied max_completion_tokens floor. +func AnthropicToChatCompletionsRequestWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ChatCompletionsRequest, error) { if req == nil { return nil, fmt.Errorf("anthropic request is nil") } @@ -63,8 +69,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxCompletionTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index b278c28a83..13cc945ca7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -267,6 +267,43 @@ func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) { require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens) } +func TestAnthropicToChatCompletionsRequestWithMinOutputTokens_ConfiguredFloor(t *testing.T) { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 16, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + + out, err := AnthropicToChatCompletionsRequestWithMinOutputTokens(req, 64) + require.NoError(t, err) + require.NotNil(t, out.MaxCompletionTokens) + require.Equal(t, 64, *out.MaxCompletionTokens) +} + +func TestAnthropicToResponses_ConfiguredMaxTokensFloor(t *testing.T) { + for _, input := range []int{16, 64, 127} { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: input, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, input, *out.MaxOutputTokens) + } + + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 1, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, 16, *out.MaxOutputTokens) +} + func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.4", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 1a27ef2374..b94bc5c394 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -2,6 +2,7 @@ package apicompat import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -290,6 +291,46 @@ func mustMarshalJSON(t *testing.T, value any) []byte { } func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { + t.Run("default floor", func(t *testing.T) { + maxTokens := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, DefaultMinMaxOutputTokens, *resp.MaxOutputTokens) + }) + + t.Run("configured floor", func(t *testing.T) { + for _, input := range []int{16, 64, 127, 128} { + input := input + t.Run(fmt.Sprintf("input_%d", input), func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, input, *resp.MaxOutputTokens) + }) + } + input := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) + t.Run("max_tokens", func(t *testing.T) { maxTokens := 100 req := &ChatCompletionsRequest{ @@ -318,6 +359,21 @@ func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { require.NotNil(t, resp.MaxOutputTokens) assert.Equal(t, 500, *resp.MaxOutputTokens) }) + + t.Run("max_completion_tokens_preferred before configured floor", func(t *testing.T) { + maxTokens := 500 + maxCompletion := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + MaxCompletionTokens: &maxCompletion, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) } func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 5f4267c831..9f221a064a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -18,6 +18,12 @@ type chatMessageContent struct { // true. store is always false and reasoning.encrypted_content is always // included so that the response translator has full context. func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) { + return ChatCompletionsToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// ChatCompletionsToResponsesWithMinOutputTokens converts a Chat Completions +// request using the supplied max_output_tokens floor. +func ChatCompletionsToResponsesWithMinOutputTokens(req *ChatCompletionsRequest, minOutputTokens int) (*ResponsesRequest, error) { if err := openai.ValidateGPT61SolReasoningEffort(req.Model, req.ReasoningEffort); err != nil { return nil, err } @@ -62,8 +68,8 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } if maxTokens > 0 { v := maxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b5864f2f2f..956101bd73 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -860,6 +860,9 @@ func (d ChatDelta) reasoningText() *string { // Shared constants // --------------------------------------------------------------------------- -// minMaxOutputTokens is the floor for max_output_tokens in a Responses request. -// Very small values may cause upstream API errors, so we enforce a minimum. -const minMaxOutputTokens = 128 +// DefaultMinMaxOutputTokens is the default floor for max_output_tokens in a +// Responses request. Very small values may cause upstream API errors, so we +// enforce a minimum. +const DefaultMinMaxOutputTokens = 128 + +const minMaxOutputTokens = DefaultMinMaxOutputTokens diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index b62e32c842..60ecd977ed 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -71,7 +71,7 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions( return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "model is required") } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&request) + responsesRequest, err := chatCompletionsToResponses(settingServiceConfig(s.settingService), &request) if err != nil { return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/apicompat_config.go b/backend/internal/service/apicompat_config.go new file mode 100644 index 0000000000..e9605e0a5c --- /dev/null +++ b/backend/internal/service/apicompat_config.go @@ -0,0 +1,28 @@ +package service + +import ( + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +func configuredMinOutputTokens(cfg *config.Config) int { + if cfg != nil && cfg.Gateway.MinOutputTokens >= 16 { + return cfg.Gateway.MinOutputTokens + } + return apicompat.DefaultMinMaxOutputTokens +} + +func chatCompletionsToResponses(cfg *config.Config, req *apicompat.ChatCompletionsRequest) (*apicompat.ResponsesRequest, error) { + 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 + } + return settingService.cfg +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 60b14488cd..83d82e6ed0 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -44,7 +44,7 @@ func (s *GatewayService) ForwardAsChatCompletions( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index db8033ebe4..189c086192 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,7 +37,7 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() - normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) + normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if err != nil { return nil, err } diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 1ad2b487e8..848b40d887 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -42,7 +42,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( clientStream := ccReq.Stream includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index b7341ccda3..af4eae36ea 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -231,7 +231,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). chatReq.Model = upstreamModel - responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err = chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 6c0affe2bf..f76a457370 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -57,7 +57,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 5faa086380..27aafd997d 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -65,7 +65,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } - legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body) + legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if legacyIngressErr != nil { return nil, legacyIngressErr } diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 284b0dcf9f..546b3007f2 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -541,7 +541,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert grok chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 75c83c7138..18c4fb1372 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -144,7 +144,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Convert Anthropic → Responses after compatibility-only replay guard. anthropicReq.Model = upstreamModel - responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) + responsesReq, err := anthropicToResponses(s.cfg, &anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 2d2c997b97..d12918a3bb 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -52,7 +52,10 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( clientStream := anthropicReq.Stream // 2. Anthropic → Chat Completions (direct, no Responses intermediary) - chatReq, err := apicompat.AnthropicToChatCompletionsRequest(&anthropicReq) + chatReq, err := apicompat.AnthropicToChatCompletionsRequestWithMinOutputTokens( + &anthropicReq, + configuredMinOutputTokens(s.cfg), + ) if err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, fmt.Errorf("convert anthropic to chat completions: %w", err) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index bf74b242d9..6feadb8fb6 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -214,6 +214,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if account != nil && account.IsOpenAI() { responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ + Config: s.cfg, ResponsesLite: responsesLite, Compact: isOpenAIResponsesCompactPath(c), }) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 7ae18793e7..52ca1d57b4 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -1433,7 +1434,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ResponsesLite: responsesLite}) } +func normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(cfg *config.Config, body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { + return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{Config: cfg, ResponsesLite: responsesLite}) +} + type openAIResponsesCompatibilityOptions struct { + Config *config.Config ResponsesLite bool // Compact marks the /responses/compact wire shape, which is left as-is // by request-shape compatibility rewrites such as web_search history. @@ -1449,7 +1455,7 @@ func normalizeOpenAIResponsesCompatibilityBodyWithOptions(body []byte, account * changed := false if account.IsOpenAIOAuthLike() { var err error - normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body) + normalized, changed, err = normalizeOpenAIResponsesLegacyIngressWithConfig(opts.Config, body) if err != nil { return body, false, err } diff --git a/backend/internal/service/openai_responses_ingress_compat.go b/backend/internal/service/openai_responses_ingress_compat.go index b5a289dd3e..d7a2adafb8 100644 --- a/backend/internal/service/openai_responses_ingress_compat.go +++ b/backend/internal/service/openai_responses_ingress_compat.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/tidwall/gjson" ) @@ -13,6 +14,10 @@ import ( // payloads that a few Responses clients still emit. Native Responses input is // always authoritative because this path has no separate full-replay attempt. func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { + return normalizeOpenAIResponsesLegacyIngressWithConfig(nil, body) +} + +func normalizeOpenAIResponsesLegacyIngressWithConfig(cfg *config.Config, body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } @@ -43,7 +48,7 @@ func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { if hasMessages { messages, messagesAreArray := messagesValue.([]any) if messagesAreArray && len(messages) > 0 { - legacy, err := convertLegacyResponsesMessages(body) + legacy, err := convertLegacyResponsesMessages(cfg, body) if err != nil { return body, false, err } @@ -105,13 +110,13 @@ type convertedLegacyResponsesMessages struct { hasReasoningEffort bool } -func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) { +func convertLegacyResponsesMessages(cfg *config.Config, body []byte) (convertedLegacyResponsesMessages, error) { var converted convertedLegacyResponsesMessages var chatRequest apicompat.ChatCompletionsRequest if err := json.Unmarshal(body, &chatRequest); err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest) + responsesRequest, err := chatCompletionsToResponses(cfg, &chatRequest) if err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 09ea02276a..10bb77450d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -269,7 +269,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = next } responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) - if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, normalized, account, responsesLite); compatibilityErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) } else if compatibilityChanged { normalized = compatibilityBody diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 5b5f25e37f..e7d0a0d09b 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -746,7 +746,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized @@ -998,7 +998,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, payload, account, responsesLite); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 6b8b049838..36e92361d4 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -217,6 +217,9 @@ security: # 网关配置 # ============================================================================= gateway: + # Minimum max_output_tokens when converting Chat Completions or Anthropic requests to Responses (minimum 16, default 128) + # Chat Completions 或 Anthropic 请求转换为 Responses 时的最小 max_output_tokens(最小 16,默认 128) + min_output_tokens: 128 # Timeout for waiting upstream response headers (seconds) # 等待上游响应头超时时间(秒) response_header_timeout: 600