| @@ -18,12 +18,14 @@ import ( | |||||
| "github.com/QuantumNous/new-api/relay/constant" | "github.com/QuantumNous/new-api/relay/constant" | ||||
| "github.com/QuantumNous/new-api/relay/helper" | "github.com/QuantumNous/new-api/relay/helper" | ||||
| "github.com/QuantumNous/new-api/service" | "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/setting/operation_setting" | ||||
| "github.com/QuantumNous/new-api/types" | "github.com/QuantumNous/new-api/types" | ||||
| "github.com/bytedance/gopkg/util/gopool" | "github.com/bytedance/gopkg/util/gopool" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/gorilla/websocket" | "github.com/gorilla/websocket" | ||||
| "github.com/tidwall/sjson" | |||||
| ) | ) | ||||
| func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { | 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) { | func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { | ||||
| fullRequestURL, err := a.GetRequestURL(info) | fullRequestURL, err := a.GetRequestURL(info) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -285,7 +310,15 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody | |||||
| if common2.DebugEnabled { | if common2.DebugEnabled { | ||||
| common2.SysLog("fullRequestURL: " + fullRequestURL) | 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 { | if err != nil { | ||||
| return nil, fmt.Errorf("new request failed: %w", err) | return nil, fmt.Errorf("new request failed: %w", err) | ||||
| } | } | ||||
| @@ -6,10 +6,82 @@ import ( | |||||
| "testing" | "testing" | ||||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | relaycommon "github.com/QuantumNous/new-api/relay/common" | ||||
| "github.com/QuantumNous/new-api/setting/model_setting" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | "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) { | func TestProcessHeaderOverride_ChannelTestSkipsPassthroughRules(t *testing.T) { | ||||
| t.Parallel() | t.Parallel() | ||||
| @@ -34,6 +34,7 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe | |||||
| type GlobalSettings struct { | type GlobalSettings struct { | ||||
| PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` | PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` | ||||
| PassthroughApplyModelMapping bool `json:"passthrough_apply_model_mapping"` | |||||
| ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` | ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` | ||||
| ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` | ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` | ||||
| EndpointFormatRestrictionEnabled bool `json:"endpoint_format_restriction_enabled"` | EndpointFormatRestrictionEnabled bool `json:"endpoint_format_restriction_enabled"` | ||||
| @@ -42,6 +43,7 @@ type GlobalSettings struct { | |||||
| // 默认配置 | // 默认配置 | ||||
| var defaultOpenaiSettings = GlobalSettings{ | var defaultOpenaiSettings = GlobalSettings{ | ||||
| PassThroughRequestEnabled: false, | PassThroughRequestEnabled: false, | ||||
| PassthroughApplyModelMapping: false, | |||||
| ThinkingModelBlacklist: []string{ | ThinkingModelBlacklist: []string{ | ||||
| "moonshotai/kimi-k2-thinking", | "moonshotai/kimi-k2-thinking", | ||||
| "kimi-k2-thinking", | "kimi-k2-thinking", | ||||
| @@ -40,6 +40,7 @@ const ModelSetting = () => { | |||||
| 'claude.default_max_tokens': '', | 'claude.default_max_tokens': '', | ||||
| 'claude.thinking_adapter_budget_tokens_percentage': 0.8, | 'claude.thinking_adapter_budget_tokens_percentage': 0.8, | ||||
| 'global.pass_through_request_enabled': false, | 'global.pass_through_request_enabled': false, | ||||
| 'global.passthrough_apply_model_mapping': false, | |||||
| 'global.endpoint_format_restriction_enabled': false, | 'global.endpoint_format_restriction_enabled': false, | ||||
| 'global.thinking_model_blacklist': '[]', | 'global.thinking_model_blacklist': '[]', | ||||
| 'global.chat_completions_to_responses_policy': '{}', | 'global.chat_completions_to_responses_policy': '{}', | ||||
| @@ -78,7 +79,11 @@ const ModelSetting = () => { | |||||
| } | } | ||||
| } | } | ||||
| // Keep boolean config keys ending with enabled/Enabled so UI parses correctly. | // 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); | newInputs[item.key] = toBoolean(item.value); | ||||
| } else { | } else { | ||||
| newInputs[item.key] = item.value; | newInputs[item.key] = item.value; | ||||
| @@ -68,6 +68,7 @@ const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify( | |||||
| const defaultGlobalSettingInputs = { | const defaultGlobalSettingInputs = { | ||||
| 'global.pass_through_request_enabled': false, | 'global.pass_through_request_enabled': false, | ||||
| 'global.passthrough_apply_model_mapping': false, | |||||
| 'global.endpoint_format_restriction_enabled': false, | 'global.endpoint_format_restriction_enabled': false, | ||||
| 'global.thinking_model_blacklist': '[]', | 'global.thinking_model_blacklist': '[]', | ||||
| 'global.chat_completions_to_responses_policy': '{}', | 'global.chat_completions_to_responses_policy': '{}', | ||||
| @@ -206,6 +207,23 @@ export default function SettingGlobalModel(props) { | |||||
| /> | /> | ||||
| </Col> | </Col> | ||||
| </Row> | </Row> | ||||
| <Row> | |||||
| <Col xs={24} sm={12} md={8} lg={8} xl={8}> | |||||
| <Form.Switch | |||||
| label={t('透传时应用模型映射')} | |||||
| field={'global.passthrough_apply_model_mapping'} | |||||
| onChange={(value) => | |||||
| setInputs({ | |||||
| ...inputs, | |||||
| 'global.passthrough_apply_model_mapping': value, | |||||
| }) | |||||
| } | |||||
| extraText={t( | |||||
| '开启后,在请求透传模式下,如果渠道配置了模型映射,将把请求体中的 model 替换为映射后的上游模型名', | |||||
| )} | |||||
| /> | |||||
| </Col> | |||||
| </Row> | |||||
| <Row> | <Row> | ||||
| <Col xs={24} sm={12} md={8} lg={8} xl={8}> | <Col xs={24} sm={12} md={8} lg={8} xl={8}> | ||||
| <Form.Switch | <Form.Switch | ||||
| @@ -131,8 +131,16 @@ export default function SettingsHeaderNavModules(props) { | |||||
| async function onSubmit() { | async function onSubmit() { | ||||
| const customLinkText = (headerNavModules.customLink?.text || '').trim(); | const customLinkText = (headerNavModules.customLink?.text || '').trim(); | ||||
| const customLinkUrl = (headerNavModules.customLink?.url || '').trim(); | const customLinkUrl = (headerNavModules.customLink?.url || '').trim(); | ||||
| const normalizedHeaderNavModules = { | |||||
| ...headerNavModules, | |||||
| customLink: { | |||||
| ...headerNavModules.customLink, | |||||
| text: customLinkText, | |||||
| url: customLinkUrl, | |||||
| }, | |||||
| }; | |||||
| if ( | if ( | ||||
| headerNavModules.customLink?.enabled && | |||||
| normalizedHeaderNavModules.customLink?.enabled && | |||||
| (!customLinkText || !customLinkUrl) | (!customLinkText || !customLinkUrl) | ||||
| ) { | ) { | ||||
| showError(t('启用自定义链接前请先填写完整的导航文案和跳转链接')); | showError(t('启用自定义链接前请先填写完整的导航文案和跳转链接')); | ||||
| @@ -142,7 +150,7 @@ export default function SettingsHeaderNavModules(props) { | |||||
| try { | try { | ||||
| const res = await API.put('/api/option/', { | const res = await API.put('/api/option/', { | ||||
| key: 'HeaderNavModules', | key: 'HeaderNavModules', | ||||
| value: JSON.stringify(headerNavModules), | |||||
| value: JSON.stringify(normalizedHeaderNavModules), | |||||
| }); | }); | ||||
| const { success, message } = res.data; | const { success, message } = res.data; | ||||
| if (success) { | if (success) { | ||||
| @@ -153,7 +161,7 @@ export default function SettingsHeaderNavModules(props) { | |||||
| type: 'set', | type: 'set', | ||||
| payload: { | payload: { | ||||
| ...statusState.status, | ...statusState.status, | ||||
| HeaderNavModules: JSON.stringify(headerNavModules), | |||||
| HeaderNavModules: JSON.stringify(normalizedHeaderNavModules), | |||||
| }, | }, | ||||
| }); | }); | ||||