Merge pull request #8094 from wucm667/feat/issue-8092-configurable-min-output

feat(apicompat): configure minimum output token floor
This commit is contained in:
Wesley Liddick
2026-10-15 16:40:06 +08:00
committed by GitHub
25 changed files with 216 additions and 26 deletions
+7
View File
@@ -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)
+23
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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",
@@ -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) {
@@ -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
}
+6 -3
View File
@@ -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
@@ -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())
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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())
}
@@ -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)
}
@@ -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)
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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)
@@ -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),
})
@@ -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
}
@@ -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)
}
@@ -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
@@ -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