From 3f2c0aeda73c08b144c6c901e78959cfa24ff690 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:00:27 +0800 Subject: [PATCH] feat: advanced custom channel (#5590) --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 3 + dto/channel_settings.go | 206 +++++- model/channel.go | 20 + relay/channel/advancedcustom/adaptor.go | 545 ++++++++++++++ relay/channel/advancedcustom/adaptor_test.go | 317 ++++++++ relay/common/relay_info.go | 37 +- relay/relay_adaptor.go | 3 + web/default/src/components/ui/select.tsx | 5 +- .../dialogs/advanced-custom-editor-dialog.tsx | 674 ++++++++++++++++++ .../drawers/channel-mutate-drawer.tsx | 82 +++ .../src/features/channels/constants.ts | 5 +- .../features/channels/lib/advanced-custom.ts | 616 ++++++++++++++++ .../channels/lib/channel-form-errors.ts | 1 + .../src/features/channels/lib/channel-form.ts | 56 ++ .../channels/lib/channel-type-config.ts | 10 + .../features/channels/lib/channel-utils.ts | 1 + .../src/features/channels/lib/index.ts | 1 + web/default/src/features/channels/types.ts | 33 + web/default/src/i18n/locales/en.json | 76 +- web/default/src/i18n/locales/fr.json | 76 +- web/default/src/i18n/locales/ja.json | 76 +- web/default/src/i18n/locales/ru.json | 76 +- web/default/src/i18n/locales/vi.json | 76 +- web/default/src/i18n/locales/zh.json | 76 +- 26 files changed, 3012 insertions(+), 62 deletions(-) create mode 100644 relay/channel/advancedcustom/adaptor.go create mode 100644 relay/channel/advancedcustom/adaptor_test.go create mode 100644 web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx create mode 100644 web/default/src/features/channels/lib/advanced-custom.ts diff --git a/common/api_type.go b/common/api_type.go index 39c1fe9a..c198ffc0 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeReplicate case constant.ChannelTypeCodex: apiType = constant.APITypeCodex + case constant.ChannelTypeAdvancedCustom: + apiType = constant.APITypeAdvancedCustom } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 536ebd2c..f3657a11 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -36,5 +36,6 @@ const ( APITypeMiniMax APITypeReplicate APITypeCodex + APITypeAdvancedCustom APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index e1489512..45ec9a44 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeAdvancedCustom = 58 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "", //58 } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "ChatGPT Subscription (Codex)", + ChannelTypeAdvancedCustom: "Advanced Custom", } func GetChannelTypeName(channelType int) string { diff --git a/dto/channel_settings.go b/dto/channel_settings.go index b6a1ab9f..bfe2ef8d 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -1,5 +1,11 @@ package dto +import ( + "fmt" + "net/url" + "strings" +) + type ChannelSettings struct { ForceFormat bool `json:"force_format,omitempty"` ThinkingToContent bool `json:"thinking_to_content,omitempty"` @@ -24,23 +30,24 @@ const ( ) type ChannelOtherSettings struct { - AzureResponsesVersion string `json:"azure_responses_version,omitempty"` - VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" - OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` - ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true - AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) - AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规 - AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) - AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) - DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) - AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) - AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` - UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 - UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 - UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间 - UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型 - UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型 - UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型 + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" + OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规 + AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) + AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` + UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 + UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 + UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间 + UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型 + UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型 + UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型 + AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"` } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { @@ -49,3 +56,168 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { } return *s.OpenRouterEnterprise } + +const ( + AdvancedCustomConverterNone = "none" + AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions" + AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages" + AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses" + AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions" + AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content" +) + +const ( + AdvancedCustomAuthTypeNone = "none" + AdvancedCustomAuthTypeHeader = "header" + AdvancedCustomAuthTypeQuery = "query" +) + +type AdvancedCustomConfig struct { + Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"` + Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"` +} + +type AdvancedCustomRoute struct { + IncomingPath string `json:"incoming_path,omitempty"` + UpstreamPath string `json:"upstream_path,omitempty"` + Converter string `json:"converter,omitempty"` + Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"` +} + +type AdvancedCustomFallback struct { + Enabled bool `json:"enabled,omitempty"` +} + +type AdvancedCustomRouteAuth struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} + +func IsAdvancedCustomConverterAllowed(converter string) bool { + switch converter { + case AdvancedCustomConverterNone, + AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, + AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages, + AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions, + AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: + return true + default: + return false + } +} + +func (c *AdvancedCustomConfig) Validate() error { + if c == nil { + return fmt.Errorf("advanced_custom is required") + } + if len(c.Routes) == 0 && !c.Fallback.Enabled { + return fmt.Errorf("advanced_custom requires at least one route or enabled fallback") + } + + seenPaths := make(map[string]struct{}, len(c.Routes)) + for i := range c.Routes { + route := c.Routes[i] + route.IncomingPath = strings.TrimSpace(route.IncomingPath) + upstreamPath := strings.TrimSpace(route.UpstreamPath) + route.Converter = strings.TrimSpace(route.Converter) + if route.Converter == "" { + route.Converter = AdvancedCustomConverterNone + } + + if route.IncomingPath == "" { + return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i) + } + if !strings.HasPrefix(route.IncomingPath, "/") { + return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i) + } + if strings.Contains(route.IncomingPath, "?") { + return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i) + } + if _, exists := seenPaths[route.IncomingPath]; exists { + return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must be unique: %s", i, route.IncomingPath) + } + seenPaths[route.IncomingPath] = struct{}{} + + if upstreamPath == "" { + return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i) + } + if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil { + return err + } + + if !IsAdvancedCustomConverterAllowed(route.Converter) { + return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter) + } + if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil { + return err + } + if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil { + return err + } + } + + return nil +} + +func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error { + if strings.HasPrefix(upstreamPath, "/") { + if strings.HasPrefix(upstreamPath, "//") { + return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index) + } + return nil + } + + parsedURL, err := url.Parse(upstreamPath) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index) + } + if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") { + return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index) + } + return nil +} + +func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error { + switch converter { + case AdvancedCustomConverterNone: + return nil + case AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions: + if incomingPath == "/v1/messages" { + return nil + } + case AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages, + AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: + if incomingPath == "/v1/chat/completions" { + return nil + } + case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: + if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") { + return nil + } + } + return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter) +} + +func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error { + if auth == nil { + return nil + } + authType := strings.TrimSpace(auth.Type) + switch authType { + case AdvancedCustomAuthTypeNone: + return nil + case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery: + if strings.TrimSpace(auth.Name) == "" { + return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index) + } + if strings.TrimSpace(auth.Value) == "" { + return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index) + } + return nil + default: + return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type) + } +} diff --git a/model/channel.go b/model/channel.go index 78a1477c..d4e726d0 100644 --- a/model/channel.go +++ b/model/channel.go @@ -945,6 +945,26 @@ func (channel *Channel) ValidateSettings() error { return err } } + channelOtherSettings := &dto.ChannelOtherSettings{} + if channel.OtherSettings != "" { + err := common.UnmarshalJsonStr(channel.OtherSettings, channelOtherSettings) + if err != nil { + return err + } + } + if channel.Type == constant.ChannelTypeAdvancedCustom { + if channelOtherSettings.AdvancedCustom == nil { + return fmt.Errorf("advanced_custom is required") + } + if channelOtherSettings.AdvancedCustom.Fallback.Enabled && (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") { + return fmt.Errorf("base_url is required when advanced_custom advanced_fallback is enabled") + } + } + if channelOtherSettings.AdvancedCustom != nil { + if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil { + return err + } + } return nil } diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go new file mode 100644 index 00000000..c0edbf5d --- /dev/null +++ b/relay/channel/advancedcustom/adaptor.go @@ -0,0 +1,545 @@ +package advancedcustom + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/gemini" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +const ChannelName = "advanced_custom" + +const advancedCustomModelPlaceholder = "{model}" + +type Adaptor struct { + openaiAdaptor openai.Adaptor + claudeAdaptor claude.Adaptor + geminiAdaptor gemini.Adaptor + + resolved bool + fallback bool + converted bool + route dto.AdvancedCustomRoute + converter string +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.openaiAdaptor.Init(info) + a.claudeAdaptor.Init(info) + a.geminiAdaptor.Init(info) +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if a.fallback || converter == dto.AdvancedCustomConverterNone { + return a.convertOpenAICompatibleRequest(c, info, request) + } + + switch converter { + case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages: + return a.claudeAdaptor.ConvertOpenAIRequest(c, info, request) + case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses: + if request == nil { + return nil, errors.New("request is nil") + } + return service.ChatCompletionsRequestToResponsesRequest(request) + case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: + return a.geminiAdaptor.ConvertOpenAIRequest(c, info, request) + default: + return nil, fmt.Errorf("converter %q does not support OpenAI chat completions requests", converter) + } +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if a.fallback { + return a.convertClaudeToOpenAICompatibleRequest(c, info, request) + } + + switch converter { + case dto.AdvancedCustomConverterNone: + return a.claudeAdaptor.ConvertClaudeRequest(c, info, request) + case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions: + return a.convertClaudeToOpenAICompatibleRequest(c, info, request) + default: + return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter) + } +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if a.fallback { + return a.convertGeminiToOpenAICompatibleRequest(c, info, request) + } + + switch converter { + case dto.AdvancedCustomConverterNone: + return a.geminiAdaptor.ConvertGeminiRequest(c, info, request) + case dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: + return a.convertGeminiToOpenAICompatibleRequest(c, info, request) + default: + return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter) + } +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if converter != dto.AdvancedCustomConverterNone { + return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter) + } + return a.convertOpenAICompatibleResponsesRequest(c, info, request) +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if converter != dto.AdvancedCustomConverterNone { + return nil, fmt.Errorf("converter %q does not support embedding requests", converter) + } + return a.convertOpenAICompatibleEmbeddingRequest(c, info, request) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if converter != dto.AdvancedCustomConverterNone { + return nil, fmt.Errorf("converter %q does not support audio requests", converter) + } + return a.convertOpenAICompatibleAudioRequest(c, info, request) +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + converter, err := a.resolveForConversion(c, info) + if err != nil { + return nil, err + } + if converter != dto.AdvancedCustomConverterNone { + return nil, fmt.Errorf("converter %q does not support image requests", converter) + } + return a.convertOpenAICompatibleImageRequest(c, info, request) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + a.converted = true + return a.openaiAdaptor.ConvertRerankRequest(c, relayMode, request) +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if err := a.resolve(nil, info); err != nil { + return "", err + } + if a.fallback { + return a.withTemporaryChannelType(info, constant.ChannelTypeOpenAI, func() (string, error) { + return a.openaiAdaptor.GetRequestURL(info) + }) + } + return a.routeURL(info) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + if err := a.resolve(c, info); err != nil { + return err + } + if a.fallback { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + err := a.openaiAdaptor.SetupRequestHeader(c, header, info) + info.ChannelType = old + return err + } + + channel.SetupApiRequestHeader(info, c, header) + auth := a.route.Auth + if auth == nil { + header.Set("Authorization", "Bearer "+info.ApiKey) + } else { + switch strings.TrimSpace(auth.Type) { + case dto.AdvancedCustomAuthTypeNone: + case dto.AdvancedCustomAuthTypeHeader: + header.Set(strings.TrimSpace(auth.Name), applyAuthTemplate(auth.Value, info.ApiKey)) + case dto.AdvancedCustomAuthTypeQuery: + default: + return fmt.Errorf("invalid advanced custom auth type: %s", auth.Type) + } + } + + if shouldApplyClaudeHeaders(a.converter, info) { + applyClaudeHeaders(c, header, info) + } + + return nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if err := a.resolve(c, info); err != nil { + return nil, err + } + if !a.converted && (a.fallback || a.converter != dto.AdvancedCustomConverterNone) { + return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body") + } + + if info.RelayMode == relayconstant.RelayModeAudioTranscription || + info.RelayMode == relayconstant.RelayModeAudioTranslation || + (info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c)) { + return channel.DoFormRequest(a, c, info, requestBody) + } + if info.RelayMode == relayconstant.RelayModeRealtime { + return channel.DoWssRequest(a, c, info, requestBody) + } + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if err := a.resolve(c, info); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + if a.fallback { + return a.openaiAdaptor.DoResponse(c, resp, info) + } + + switch a.converter { + case dto.AdvancedCustomConverterNone: + return a.doNativeResponse(c, resp, info) + case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, + dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: + return a.openaiAdaptor.DoResponse(c, resp, info) + case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages: + return a.claudeAdaptor.DoResponse(c, resp, info) + case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: + return a.geminiAdaptor.DoResponse(c, resp, info) + case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses: + if info.IsStream { + return openai.OaiResponsesToChatStreamHandler(c, info, resp) + } + return openai.OaiResponsesToChatHandler(c, info, resp) + default: + return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", a.converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } +} + +func (a *Adaptor) GetModelList() []string { + models := make([]string, 0, len(openai.ModelList)+len(claude.ModelList)+len(gemini.ModelList)) + models = append(models, openai.ModelList...) + models = append(models, claude.ModelList...) + models = append(models, gemini.ModelList...) + return lo.Uniq(models) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +func (a *Adaptor) doNativeResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return a.claudeAdaptor.DoResponse(c, resp, info) + case types.RelayFormatGemini: + return a.geminiAdaptor.DoResponse(c, resp, info) + default: + return a.openaiAdaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) resolveForConversion(c *gin.Context, info *relaycommon.RelayInfo) (string, error) { + if err := a.resolve(c, info); err != nil { + return "", err + } + a.converted = true + return a.converter, nil +} + +func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error { + if a.resolved { + return nil + } + if info == nil { + return errors.New("missing relay info") + } + config := info.ChannelOtherSettings.AdvancedCustom + if config == nil { + return errors.New("advanced_custom is required") + } + if err := config.Validate(); err != nil { + return err + } + + incomingPath := incomingRequestPath(c, info) + route, ok := lo.Find(config.Routes, func(route dto.AdvancedCustomRoute) bool { + return matchIncomingPath(strings.TrimSpace(route.IncomingPath), incomingPath) + }) + if ok { + route.Converter = strings.TrimSpace(route.Converter) + if route.Converter == "" { + route.Converter = dto.AdvancedCustomConverterNone + } + a.route = route + a.converter = route.Converter + a.resolved = true + return nil + } + if config.Fallback.Enabled { + a.fallback = true + a.converter = dto.AdvancedCustomConverterNone + a.resolved = true + return nil + } + return fmt.Errorf("advanced custom route not found for path: %s", incomingPath) +} + +func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string { + if c != nil && c.Request != nil && c.Request.URL != nil { + return c.Request.URL.Path + } + if info == nil { + return "" + } + return strings.Split(info.RequestURLPath, "?")[0] +} + +func matchIncomingPath(configuredPath string, requestPath string) bool { + if matchIncomingPathTemplate(configuredPath, requestPath) { + return true + } + if strings.Contains(configuredPath, ":generateContent") { + streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1) + return matchIncomingPathTemplate(streamPath, requestPath) + } + return false +} + +func matchIncomingPathTemplate(configuredPath string, requestPath string) bool { + if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) { + return configuredPath == requestPath + } + + parts := strings.Split(configuredPath, advancedCustomModelPlaceholder) + if len(parts) != 2 { + return false + } + if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) { + return false + } + + model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1]) + return model != "" && !strings.Contains(model, "/") +} + +func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) { + parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info) + if err != nil { + return "", err + } + if shouldUseGeminiStreamURL(a.converter, info) { + useGeminiStreamGenerateContentURL(parsedURL) + } + if info != nil && info.RelayMode == relayconstant.RelayModeRealtime { + switch parsedURL.Scheme { + case "https": + parsedURL.Scheme = "wss" + case "http": + parsedURL.Scheme = "ws" + } + } + if a.route.Auth != nil && strings.TrimSpace(a.route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery { + query := parsedURL.Query() + query.Set(strings.TrimSpace(a.route.Auth.Name), applyAuthTemplate(a.route.Auth.Value, info.ApiKey)) + parsedURL.RawQuery = query.Encode() + } + return parsedURL.String(), nil +} + +func resolveUpstreamTargetURL(upstreamPath string, info *relaycommon.RelayInfo) (*url.URL, error) { + if strings.HasPrefix(upstreamPath, "/") { + if strings.HasPrefix(upstreamPath, "//") { + return nil, errors.New("advanced custom upstream path must be a full URL or a path starting with /") + } + if info == nil || strings.TrimSpace(info.ChannelBaseUrl) == "" { + return nil, errors.New("channel base URL is required when advanced custom upstream path is relative") + } + return joinBaseURLAndUpstreamPath(info.ChannelBaseUrl, upstreamPath) + } + + parsedURL, err := url.Parse(upstreamPath) + if err != nil { + return nil, err + } + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, errors.New("advanced custom upstream path must be a full URL or a path starting with /") + } + if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") { + return nil, errors.New("advanced custom upstream path must use http or https") + } + return parsedURL, nil +} + +func joinBaseURLAndUpstreamPath(baseURL string, upstreamPath string) (*url.URL, error) { + parsedBaseURL, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return nil, err + } + if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" { + return nil, errors.New("channel base URL must be a full URL when advanced custom upstream path is relative") + } + if !strings.EqualFold(parsedBaseURL.Scheme, "http") && !strings.EqualFold(parsedBaseURL.Scheme, "https") { + return nil, errors.New("channel base URL must use http or https when advanced custom upstream path is relative") + } + + parsedPath, err := url.Parse(upstreamPath) + if err != nil { + return nil, err + } + parsedBaseURL.Path = strings.TrimRight(parsedBaseURL.Path, "/") + "/" + strings.TrimLeft(parsedPath.Path, "/") + parsedBaseURL.RawPath = "" + parsedBaseURL.RawQuery = parsedPath.RawQuery + parsedBaseURL.Fragment = parsedPath.Fragment + return parsedBaseURL, nil +} + +func applyUpstreamPathTemplate(upstreamPath string, info *relaycommon.RelayInfo) string { + if info == nil { + return upstreamPath + } + return strings.ReplaceAll(upstreamPath, advancedCustomModelPlaceholder, info.UpstreamModelName) +} + +func shouldUseGeminiStreamURL(converter string, info *relaycommon.RelayInfo) bool { + return info != nil && + info.IsStream && + converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent +} + +func useGeminiStreamGenerateContentURL(parsedURL *url.URL) { + if strings.Contains(parsedURL.Path, ":generateContent") { + parsedURL.Path = strings.Replace(parsedURL.Path, ":generateContent", ":streamGenerateContent", 1) + } + if strings.Contains(parsedURL.Path, ":streamGenerateContent") { + query := parsedURL.Query() + query.Set("alt", "sse") + parsedURL.RawQuery = query.Encode() + } +} + +func shouldApplyClaudeHeaders(converter string, info *relaycommon.RelayInfo) bool { + return converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages || + (converter == dto.AdvancedCustomConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude) +} + +func applyClaudeHeaders(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) { + anthropicVersion := "" + if c != nil && c.Request != nil { + anthropicVersion = c.Request.Header.Get("anthropic-version") + } + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + header.Set("anthropic-version", anthropicVersion) + if c != nil { + claude.CommonClaudeHeadersOperation(c, header, info) + } +} + +func applyAuthTemplate(template string, apiKey string) string { + return strings.ReplaceAll(template, "{api_key}", apiKey) +} + +func isJSONRequest(c *gin.Context) bool { + if c == nil || c.Request == nil { + return false + } + return strings.Contains(strings.ToLower(c.Request.Header.Get("Content-Type")), "application/json") +} + +func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertOpenAIRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertClaudeToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertClaudeRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertGeminiToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertGeminiRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertOpenAICompatibleResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertOpenAIResponsesRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertOpenAICompatibleEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertEmbeddingRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertOpenAICompatibleAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertAudioRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + old := info.ChannelType + info.ChannelType = constant.ChannelTypeOpenAI + converted, err := a.openaiAdaptor.ConvertImageRequest(c, info, request) + info.ChannelType = old + return converted, err +} + +func (a *Adaptor) withTemporaryChannelType(info *relaycommon.RelayInfo, channelType int, fn func() (string, error)) (string, error) { + old := info.ChannelType + info.ChannelType = channelType + value, err := fn() + info.ChannelType = old + return value, err +} diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go new file mode 100644 index 00000000..f56cf513 --- /dev/null +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -0,0 +1,317 @@ +package advancedcustom + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/messages", + UpstreamPath: "https://upstream.example/v1/chat/completions?existing=1", + Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "api_key", + Value: "{api_key}", + }, + }, + }, + }) + info.RequestURLPath = "/v1/messages?client=1" + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "https", parsedURL.Scheme) + assert.Equal(t, "upstream.example", parsedURL.Host) + assert.Equal(t, "/v1/chat/completions", parsedURL.Path) + assert.Equal(t, "1", parsedURL.Query().Get("existing")) + assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key")) +} + +func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "/proxy/v1/chat/completions?existing=1", + Converter: dto.AdvancedCustomConverterNone, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "api_key", + Value: "{api_key}", + }, + }, + }, + }) + info.ChannelBaseUrl = "https://gateway.example/base" + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "https", parsedURL.Scheme) + assert.Equal(t, "gateway.example", parsedURL.Host) + assert.Equal(t, "/base/proxy/v1/chat/completions", parsedURL.Path) + assert.Equal(t, "1", parsedURL.Query().Get("existing")) + assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key")) +} + +func TestAdaptorReturnsErrorWhenUpstreamPathNeedsMissingBaseURL(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "/v1/chat/completions", + Converter: dto.AdvancedCustomConverterNone, + }, + }, + }) + info.ChannelBaseUrl = "" + + _, err := adaptor.GetRequestURL(info) + require.Error(t, err) + assert.Contains(t, err.Error(), "base URL is required") +} + +func TestAdaptorSetupRequestHeaderUsesDefaultBearerAuth(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "https://upstream.example/v1/chat/completions", + Converter: dto.AdvancedCustomConverterNone, + }, + }, + }) + c := advancedCustomGinContext("/v1/chat/completions") + header := http.Header{} + + require.NoError(t, adaptor.SetupRequestHeader(c, &header, info)) + assert.Equal(t, "Bearer sk-test", header.Get("Authorization")) +} + +func TestAdaptorSetupRequestHeaderUsesConfiguredHeaderAuth(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "https://upstream.example/v1/chat/completions", + Converter: dto.AdvancedCustomConverterNone, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeHeader, + Name: "x-api-key", + Value: "{api_key}", + }, + }, + }, + }) + c := advancedCustomGinContext("/v1/chat/completions") + header := http.Header{} + + require.NoError(t, adaptor.SetupRequestHeader(c, &header, info)) + assert.Empty(t, header.Get("Authorization")) + assert.Equal(t, "sk-test", header.Get("x-api-key")) +} + +func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/messages", + UpstreamPath: "https://api.anthropic.com/v1/messages", + Converter: dto.AdvancedCustomConverterNone, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeHeader, + Name: "x-api-key", + Value: "{api_key}", + }, + }, + }, + }) + info.RelayFormat = types.RelayFormatClaude + c := advancedCustomGinContext("/v1/messages") + header := http.Header{} + + require.NoError(t, adaptor.SetupRequestHeader(c, &header, info)) + assert.Equal(t, "sk-test", header.Get("x-api-key")) + assert.Equal(t, "2023-06-01", header.Get("anthropic-version")) +} + +func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/messages", + UpstreamPath: "https://upstream.example/v1/chat/completions", + Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, + }, + }, + }) + info.RequestURLPath = "/v1/chat/completions" + + _, err := adaptor.GetRequestURL(info) + require.Error(t, err) + assert.Contains(t, err.Error(), "route not found") +} + +func TestAdaptorFallbackUsesOpenAICompatibleBaseURL(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Fallback: dto.AdvancedCustomFallback{Enabled: true}, + }) + info.RequestURLPath = "/v1/messages" + info.RelayFormat = types.RelayFormatClaude + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + assert.Equal(t, "https://fallback.example/v1/chat/completions", requestURL) +} + +func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "key", + Value: "{api_key}", + }, + }, + }, + }) + info.UpstreamModelName = "gemini-2.5-flash" + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "/v1beta/models/gemini-2.5-flash:generateContent", parsedURL.Path) + assert.Equal(t, "sk-test", parsedURL.Query().Get("key")) + assert.Empty(t, parsedURL.Query().Get("alt")) +} + +func TestAdaptorSwitchesGeminiGenerateContentURLForStream(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/chat/completions", + UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?existing=1", + Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent, + Auth: &dto.AdvancedCustomRouteAuth{ + Type: dto.AdvancedCustomAuthTypeQuery, + Name: "key", + Value: "{api_key}", + }, + }, + }, + }) + info.UpstreamModelName = "gemini-2.5-pro" + info.IsStream = true + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "/v1beta/models/gemini-2.5-pro:streamGenerateContent", parsedURL.Path) + assert.Equal(t, "sse", parsedURL.Query().Get("alt")) + assert.Equal(t, "1", parsedURL.Query().Get("existing")) + assert.Equal(t, "sk-test", parsedURL.Query().Get("key")) +} + +func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) { + tests := []struct { + name string + requestURLPath string + wantRequestPath string + }{ + { + name: "generate content", + requestURLPath: "/v1beta/models/gemini-2.5-flash:generateContent", + wantRequestPath: "/v1/chat/completions", + }, + { + name: "stream generate content", + requestURLPath: "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + wantRequestPath: "/v1/chat/completions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1beta/models/{model}:generateContent", + UpstreamPath: "https://upstream.example/v1/chat/completions", + Converter: dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions, + }, + }, + }) + info.RequestURLPath = tt.requestURLPath + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, tt.wantRequestPath, parsedURL.Path) + }) + } +} + +func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + RelayMode: relayconstant.RelayModeChatCompletions, + RequestURLPath: "/v1/chat/completions", + ChannelMeta: &relaycommon.ChannelMeta{ + ApiKey: "sk-test", + ChannelBaseUrl: "https://fallback.example", + ChannelType: constant.ChannelTypeAdvancedCustom, + ChannelOtherSettings: dto.ChannelOtherSettings{ + AdvancedCustom: config, + }, + }, + } +} + +func advancedCustomGinContext(path string) *gin.Context { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, path, nil) + c.Request.Header.Set("Content-Type", "application/json") + return c +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 2f7afd39..fa52e056 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -318,24 +318,25 @@ func (info *RelayInfo) ToString() string { // 定义支持流式选项的通道类型 var streamSupportedChannels = map[int]bool{ - constant.ChannelTypeOpenAI: true, - constant.ChannelTypeAnthropic: true, - constant.ChannelTypeAws: true, - constant.ChannelTypeGemini: true, - constant.ChannelCloudflare: true, - constant.ChannelTypeAzure: true, - constant.ChannelTypeVolcEngine: true, - constant.ChannelTypeOllama: true, - constant.ChannelTypeXai: true, - constant.ChannelTypeDeepSeek: true, - constant.ChannelTypeBaiduV2: true, - constant.ChannelTypeZhipu_v4: true, - constant.ChannelTypeAli: true, - constant.ChannelTypeSubmodel: true, - constant.ChannelTypeCodex: true, - constant.ChannelTypeMoonshot: true, - constant.ChannelTypeMiniMax: true, - constant.ChannelTypeSiliconFlow: true, + constant.ChannelTypeOpenAI: true, + constant.ChannelTypeAnthropic: true, + constant.ChannelTypeAws: true, + constant.ChannelTypeGemini: true, + constant.ChannelCloudflare: true, + constant.ChannelTypeAzure: true, + constant.ChannelTypeVolcEngine: true, + constant.ChannelTypeOllama: true, + constant.ChannelTypeXai: true, + constant.ChannelTypeDeepSeek: true, + constant.ChannelTypeBaiduV2: true, + constant.ChannelTypeZhipu_v4: true, + constant.ChannelTypeAli: true, + constant.ChannelTypeSubmodel: true, + constant.ChannelTypeCodex: true, + constant.ChannelTypeMoonshot: true, + constant.ChannelTypeMiniMax: true, + constant.ChannelTypeSiliconFlow: true, + constant.ChannelTypeAdvancedCustom: true, } func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo { diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2..29227446 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -5,6 +5,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/advancedcustom" "github.com/QuantumNous/new-api/relay/channel/ali" "github.com/QuantumNous/new-api/relay/channel/aws" "github.com/QuantumNous/new-api/relay/channel/baidu" @@ -120,6 +121,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &replicate.Adaptor{} case constant.APITypeCodex: return &codex.Adaptor{} + case constant.APITypeAdvancedCustom: + return &advancedcustom.Adaptor{} } return nil } diff --git a/web/default/src/components/ui/select.tsx b/web/default/src/components/ui/select.tsx index 9a0e81d1..d5553a52 100644 --- a/web/default/src/components/ui/select.tsx +++ b/web/default/src/components/ui/select.tsx @@ -159,7 +159,10 @@ function SelectItem({ )} {...props} > - + {children} . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { type ReactNode, useMemo, useState } from 'react' +import { Check, Plus, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Dialog } from '@/components/dialog' +import { + ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, + ADVANCED_CUSTOM_CONVERTER_OPTIONS, + ADVANCED_CUSTOM_TEMPLATE_OPTIONS, + type AdvancedCustomAuthMode, + buildAdvancedCustomAuth, + createAdvancedCustomConfig, + createAdvancedCustomRoute, + getAdvancedCustomAuthMode, + getAdvancedCustomIncomingPathLabel, + getAdvancedCustomIncomingPathOptions, + getAdvancedCustomTemplateConfig, + getAdvancedCustomUpstreamPathPlaceholder, + getDefaultAdvancedCustomIncomingPath, + isAdvancedCustomIncomingPathAllowed, + normalizeAdvancedCustomConfig, + parseAdvancedCustomConfig, + stringifyAdvancedCustomConfig, + validateAdvancedCustomConfig, +} from '../../lib/advanced-custom' +import type { + AdvancedCustomAuthType, + AdvancedCustomConfig, + AdvancedCustomConverter, + AdvancedCustomRoute, +} from '../../types' + +type AdvancedCustomEditorDialogProps = { + open: boolean + value: string + onOpenChange: (open: boolean) => void + onSave: (value: string) => void +} + +type AdvancedCustomEditMode = 'visual' | 'json' + +const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]' +const longSelectItemClass = + 'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal' + +function getOptionLabel( + options: ReadonlyArray<{ value: string; label: string }>, + value: string +) { + return options.find((option) => option.value === value)?.label || value +} + +export function AdvancedCustomEditorDialog({ + open, + value, + onOpenChange, + onSave, +}: AdvancedCustomEditorDialogProps) { + const { t } = useTranslation() + const [config, setConfig] = useState( + () => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() + ) + const [editMode, setEditMode] = useState('visual') + const [jsonText, setJsonText] = useState(() => + stringifyAdvancedCustomConfig( + parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() + ) + ) + const [jsonError, setJsonError] = useState('') + const [templateKey, setTemplateKey] = useState( + ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || '' + ) + const templateLabel = useMemo( + () => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey), + [templateKey] + ) + + const normalizedConfig = useMemo( + () => normalizeAdvancedCustomConfig(config), + [config] + ) + const routes = normalizedConfig.advanced_routes || [] + const validationError = useMemo( + () => validateAdvancedCustomConfig(normalizedConfig), + [normalizedConfig] + ) + + const updateRoute = (index: number, patch: Partial) => { + setConfig((current) => { + const next = normalizeAdvancedCustomConfig(current) + const nextRoutes = [...(next.advanced_routes || [])] + nextRoutes[index] = { ...nextRoutes[index], ...patch } + return { ...next, advanced_routes: nextRoutes } + }) + } + + const addRoute = () => { + setConfig((current) => { + const next = normalizeAdvancedCustomConfig(current) + return { + ...next, + advanced_routes: [ + ...(next.advanced_routes || []), + createAdvancedCustomRoute(), + ], + } + }) + } + + const removeRoute = (index: number) => { + setConfig((current) => { + const next = normalizeAdvancedCustomConfig(current) + return { + ...next, + advanced_routes: (next.advanced_routes || []).filter( + (_, routeIndex) => routeIndex !== index + ), + } + }) + } + + const setFallbackEnabled = (enabled: boolean) => { + setConfig((current) => ({ + ...normalizeAdvancedCustomConfig(current), + advanced_fallback: { enabled }, + })) + } + + const parseJsonEditorConfig = (): AdvancedCustomConfig | null => { + const parsed = parseAdvancedCustomConfig(jsonText) + if (!parsed) { + setJsonError(t('Invalid JSON')) + return null + } + + const error = validateAdvancedCustomConfig(parsed) + if (error) { + setJsonError(t(error.message)) + return null + } + + setJsonError('') + return parsed + } + + const switchToVisualMode = () => { + const parsed = parseJsonEditorConfig() + if (!parsed) return + setConfig(parsed) + setEditMode('visual') + } + + const switchToJsonMode = () => { + setJsonText(stringifyAdvancedCustomConfig(normalizedConfig)) + setJsonError('') + setEditMode('json') + } + + const handleJsonChange = (nextValue: string) => { + setJsonText(nextValue) + if (jsonError) setJsonError('') + } + + const formatJson = () => { + const parsed = parseJsonEditorConfig() + if (!parsed) return + setJsonText(stringifyAdvancedCustomConfig(parsed)) + } + + const applyTemplate = (mode: 'fill' | 'append') => { + const templateConfig = getAdvancedCustomTemplateConfig(templateKey) + let nextConfig = templateConfig + + if (mode === 'append') { + const baseConfig = + editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig + if (!baseConfig) return + const base = normalizeAdvancedCustomConfig(baseConfig) + const template = normalizeAdvancedCustomConfig(templateConfig) + nextConfig = { + advanced_routes: [ + ...(base.advanced_routes || []), + ...(template.advanced_routes || []), + ], + advanced_fallback: { + enabled: + base.advanced_fallback?.enabled === true || + template.advanced_fallback?.enabled === true, + }, + } + } + + const normalized = normalizeAdvancedCustomConfig(nextConfig) + setConfig(normalized) + setJsonText(stringifyAdvancedCustomConfig(normalized)) + setJsonError('') + } + + const saveConfig = () => { + if (editMode === 'json') { + const parsed = parseJsonEditorConfig() + if (!parsed) { + toast.error(t('Please fix JSON errors before saving')) + return + } + onSave(stringifyAdvancedCustomConfig(parsed)) + onOpenChange(false) + return + } + + if (validationError) { + toast.error(t(validationError.message)) + return + } + onSave(stringifyAdvancedCustomConfig(normalizedConfig)) + onOpenChange(false) + } + + return ( + + + + + } + > +
+
+ + {t('Mode')} + + + + +
+ + + {t('Template')} + + + + +
+
+ + {editMode === 'visual' ? ( +
+
+
+ +
+
+ {t('Fallback routing')} +
+
+ {t( + 'When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.' + )} +
+
+
+ +
+ + {validationError ? ( + + + {validationError.routeIndex !== undefined + ? `${t('Route')} ${validationError.routeIndex + 1}: ` + : ''} + {t(validationError.message)} + + + ) : null} + +
+ {routes.map((route, index) => ( + updateRoute(index, patch)} + onRemove={() => removeRoute(index)} + /> + ))} +
+
+ ) : ( +
+
+ + + {t('Advanced text editing')} + +
+