| @@ -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) | |||
| } | |||
| @@ -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() | |||
| @@ -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", | |||
| @@ -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; | |||
| @@ -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) { | |||
| /> | |||
| </Col> | |||
| </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> | |||
| <Col xs={24} sm={12} md={8} lg={8} xl={8}> | |||
| <Form.Switch | |||
| @@ -131,8 +131,16 @@ export default function SettingsHeaderNavModules(props) { | |||
| async function onSubmit() { | |||
| const customLinkText = (headerNavModules.customLink?.text || '').trim(); | |||
| const customLinkUrl = (headerNavModules.customLink?.url || '').trim(); | |||
| const normalizedHeaderNavModules = { | |||
| ...headerNavModules, | |||
| customLink: { | |||
| ...headerNavModules.customLink, | |||
| text: customLinkText, | |||
| url: customLinkUrl, | |||
| }, | |||
| }; | |||
| if ( | |||
| headerNavModules.customLink?.enabled && | |||
| normalizedHeaderNavModules.customLink?.enabled && | |||
| (!customLinkText || !customLinkUrl) | |||
| ) { | |||
| showError(t('启用自定义链接前请先填写完整的导航文案和跳转链接')); | |||
| @@ -142,7 +150,7 @@ export default function SettingsHeaderNavModules(props) { | |||
| try { | |||
| const res = await API.put('/api/option/', { | |||
| key: 'HeaderNavModules', | |||
| value: JSON.stringify(headerNavModules), | |||
| value: JSON.stringify(normalizedHeaderNavModules), | |||
| }); | |||
| const { success, message } = res.data; | |||
| if (success) { | |||
| @@ -153,7 +161,7 @@ export default function SettingsHeaderNavModules(props) { | |||
| type: 'set', | |||
| payload: { | |||
| ...statusState.status, | |||
| HeaderNavModules: JSON.stringify(headerNavModules), | |||
| HeaderNavModules: JSON.stringify(normalizedHeaderNavModules), | |||
| }, | |||
| }); | |||