diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 7b502c2..cbed0bd 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -18,12 +18,14 @@ import ( "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/tidwall/sjson" ) func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { @@ -277,6 +279,29 @@ func applyHeaderOverrideToRequest(req *http.Request, headerOverride map[string]s } } +func applyPassthroughModelMapping(body []byte, info *common.RelayInfo) []byte { + if len(body) == 0 || info == nil || info.ChannelMeta == nil { + return body + } + if !model_setting.GetGlobalSettings().PassthroughApplyModelMapping { + return body + } + if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelMeta.ChannelSetting.PassThroughBodyEnabled { + return body + } + if !info.ChannelMeta.IsModelMapped || strings.TrimSpace(info.ChannelMeta.UpstreamModelName) == "" { + return body + } + if common2.GetJsonType(body) != "object" { + return body + } + modified, err := sjson.SetBytes(body, "model", info.ChannelMeta.UpstreamModelName) + if err != nil { + return body + } + return modified +} + func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.GetRequestURL(info) if err != nil { @@ -285,7 +310,15 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody if common2.DebugEnabled { common2.SysLog("fullRequestURL: " + fullRequestURL) } - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + var bodyBytes []byte + if requestBody != nil { + bodyBytes, err = io.ReadAll(requestBody) + if err != nil { + return nil, fmt.Errorf("read request body failed: %w", err) + } + } + bodyBytes = applyPassthroughModelMapping(bodyBytes, info) + req, err := http.NewRequest(c.Request.Method, fullRequestURL, bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index 6c7834e..ede3f41 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -6,10 +6,82 @@ import ( "testing" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestApplyPassthroughModelMapping(t *testing.T) { + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{IsModelMapped: true, UpstreamModelName: "gpt-4.1"}} + + origRequestEnabled := model_setting.GetGlobalSettings().PassThroughRequestEnabled + origApplyMapping := model_setting.GetGlobalSettings().PassthroughApplyModelMapping + defer func() { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = origRequestEnabled + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = origApplyMapping + }() + + model_setting.GetGlobalSettings().PassThroughRequestEnabled = true + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = true + + got := applyPassthroughModelMapping(body, info) + assert.JSONEq(t, `{"model":"gpt-4.1","messages":[{"role":"user","content":"hi"}]}`, string(got)) +} + +func TestApplyPassthroughModelMapping_NonJSONKeepsOriginal(t *testing.T) { + body := []byte(`model=gpt-4o`) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{IsModelMapped: true, UpstreamModelName: "gpt-4.1"}} + + origRequestEnabled := model_setting.GetGlobalSettings().PassThroughRequestEnabled + origApplyMapping := model_setting.GetGlobalSettings().PassthroughApplyModelMapping + defer func() { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = origRequestEnabled + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = origApplyMapping + }() + + model_setting.GetGlobalSettings().PassThroughRequestEnabled = true + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = true + + got := applyPassthroughModelMapping(body, info) + assert.Equal(t, body, got) +} + +func TestApplyPassthroughModelMapping_DisabledKeepsOriginal(t *testing.T) { + body := []byte(`{"model":"gpt-4o"}`) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{IsModelMapped: false, UpstreamModelName: "gpt-4.1"}} + + origRequestEnabled := model_setting.GetGlobalSettings().PassThroughRequestEnabled + origApplyMapping := model_setting.GetGlobalSettings().PassthroughApplyModelMapping + defer func() { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = origRequestEnabled + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = origApplyMapping + }() + + model_setting.GetGlobalSettings().PassThroughRequestEnabled = true + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = true + + got := applyPassthroughModelMapping(body, info) + assert.Equal(t, body, got) +} + +func TestDoApiRequest_DoesNotChangeNonPassthroughBody(t *testing.T) { + origRequestEnabled := model_setting.GetGlobalSettings().PassThroughRequestEnabled + origApplyMapping := model_setting.GetGlobalSettings().PassthroughApplyModelMapping + defer func() { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = origRequestEnabled + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = origApplyMapping + }() + + model_setting.GetGlobalSettings().PassThroughRequestEnabled = false + model_setting.GetGlobalSettings().PassthroughApplyModelMapping = true + + body := []byte(`{"model":"gpt-4o"}`) + got := applyPassthroughModelMapping(body, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{IsModelMapped: true, UpstreamModelName: "gpt-4.1"}}) + assert.Equal(t, body, got) +} + func TestProcessHeaderOverride_ChannelTestSkipsPassthroughRules(t *testing.T) { t.Parallel() diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go index 56d8fb7..e175536 100644 --- a/setting/model_setting/global.go +++ b/setting/model_setting/global.go @@ -34,6 +34,7 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe type GlobalSettings struct { PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` + PassthroughApplyModelMapping bool `json:"passthrough_apply_model_mapping"` ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` EndpointFormatRestrictionEnabled bool `json:"endpoint_format_restriction_enabled"` @@ -42,6 +43,7 @@ type GlobalSettings struct { // 默认配置 var defaultOpenaiSettings = GlobalSettings{ PassThroughRequestEnabled: false, + PassthroughApplyModelMapping: false, ThinkingModelBlacklist: []string{ "moonshotai/kimi-k2-thinking", "kimi-k2-thinking", diff --git a/web/src/components/settings/ModelSetting.jsx b/web/src/components/settings/ModelSetting.jsx index 8cf42f2..71116d3 100644 --- a/web/src/components/settings/ModelSetting.jsx +++ b/web/src/components/settings/ModelSetting.jsx @@ -40,6 +40,7 @@ const ModelSetting = () => { 'claude.default_max_tokens': '', 'claude.thinking_adapter_budget_tokens_percentage': 0.8, 'global.pass_through_request_enabled': false, + 'global.passthrough_apply_model_mapping': false, 'global.endpoint_format_restriction_enabled': false, 'global.thinking_model_blacklist': '[]', 'global.chat_completions_to_responses_policy': '{}', @@ -78,7 +79,11 @@ const ModelSetting = () => { } } // Keep boolean config keys ending with enabled/Enabled so UI parses correctly. - if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) { + if ( + item.key.endsWith('Enabled') || + item.key.endsWith('enabled') || + item.key === 'global.passthrough_apply_model_mapping' + ) { newInputs[item.key] = toBoolean(item.value); } else { newInputs[item.key] = item.value; diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx index 328ba11..e30c397 100644 --- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx +++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx @@ -68,6 +68,7 @@ const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify( const defaultGlobalSettingInputs = { 'global.pass_through_request_enabled': false, + 'global.passthrough_apply_model_mapping': false, 'global.endpoint_format_restriction_enabled': false, 'global.thinking_model_blacklist': '[]', 'global.chat_completions_to_responses_policy': '{}', @@ -206,6 +207,23 @@ export default function SettingGlobalModel(props) { /> + + + + setInputs({ + ...inputs, + 'global.passthrough_apply_model_mapping': value, + }) + } + extraText={t( + '开启后,在请求透传模式下,如果渠道配置了模型映射,将把请求体中的 model 替换为映射后的上游模型名', + )} + /> + +