Commit 55b00fcf by CaIon

feat(advanced-custom): remove fallback option and enhance path matching for advanced custom routes

parent 3f2c0aed
...@@ -179,10 +179,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -179,10 +179,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}() }()
retryParam := &service.RetryParam{ retryParam := &service.RetryParam{
Ctx: c, Ctx: c,
TokenGroup: relayInfo.TokenGroup, TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName, ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0), RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
} }
relayInfo.RetryIndex = 0 relayInfo.RetryIndex = 0
relayInfo.LastError = nil relayInfo.LastError = nil
...@@ -507,10 +508,11 @@ func RelayTask(c *gin.Context) { ...@@ -507,10 +508,11 @@ func RelayTask(c *gin.Context) {
}() }()
retryParam := &service.RetryParam{ retryParam := &service.RetryParam{
Ctx: c, Ctx: c,
TokenGroup: relayInfo.TokenGroup, TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName, ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0), RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
} }
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
......
...@@ -73,8 +73,7 @@ const ( ...@@ -73,8 +73,7 @@ const (
) )
type AdvancedCustomConfig struct { type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"` Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"`
} }
type AdvancedCustomRoute struct { type AdvancedCustomRoute struct {
...@@ -84,16 +83,63 @@ type AdvancedCustomRoute struct { ...@@ -84,16 +83,63 @@ type AdvancedCustomRoute struct {
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"` Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
} }
type AdvancedCustomFallback struct {
Enabled bool `json:"enabled,omitempty"`
}
type AdvancedCustomRouteAuth struct { type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"` Value string `json:"value,omitempty"`
} }
const advancedCustomModelPlaceholder = "{model}"
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
// :generateContent <-> :streamGenerateContent equivalence.
func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
return ok
}
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
return true
}
if strings.Contains(configuredPath, ":generateContent") {
streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1)
return matchAdvancedCustomIncomingPathTemplate(streamPath, requestPath)
}
return false
}
func matchAdvancedCustomIncomingPathTemplate(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 IsAdvancedCustomConverterAllowed(converter string) bool { func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter { switch converter {
case AdvancedCustomConverterNone, case AdvancedCustomConverterNone,
...@@ -112,8 +158,8 @@ func (c *AdvancedCustomConfig) Validate() error { ...@@ -112,8 +158,8 @@ func (c *AdvancedCustomConfig) Validate() error {
if c == nil { if c == nil {
return fmt.Errorf("advanced_custom is required") return fmt.Errorf("advanced_custom is required")
} }
if len(c.Routes) == 0 && !c.Fallback.Enabled { if len(c.Routes) == 0 {
return fmt.Errorf("advanced_custom requires at least one route or enabled fallback") return fmt.Errorf("advanced_custom requires at least one route")
} }
seenPaths := make(map[string]struct{}, len(c.Routes)) seenPaths := make(map[string]struct{}, len(c.Routes))
......
...@@ -104,7 +104,8 @@ func Distribute() func(c *gin.Context) { ...@@ -104,7 +104,8 @@ func Distribute() func(c *gin.Context) {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID) preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled &&
channelSupportsRequestPath(preferred, c.Request.URL.Path) {
if usingGroup == "auto" { if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup) autoGroups := service.GetUserAutoGroup(userGroup)
...@@ -132,10 +133,11 @@ func Distribute() func(c *gin.Context) { ...@@ -132,10 +133,11 @@ func Distribute() func(c *gin.Context) {
if channel == nil { if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c, Ctx: c,
ModelName: modelRequest.Model, ModelName: modelRequest.Model,
TokenGroup: usingGroup, TokenGroup: usingGroup,
Retry: common.GetPointer(0), RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
}) })
if err != nil { if err != nil {
showGroup := usingGroup showGroup := usingGroup
...@@ -167,6 +169,20 @@ func Distribute() func(c *gin.Context) { ...@@ -167,6 +169,20 @@ func Distribute() func(c *gin.Context) {
} }
} }
// channelSupportsRequestPath reports whether a channel can serve the request path.
// Only Advanced Custom (type 58) channels are path-checked; all other channel types
// always pass. A type-58 channel is usable only when one of its routes matches.
func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool {
if channel == nil {
return false
}
if channel.Type != constant.ChannelTypeAdvancedCustom {
return true
}
config := channel.GetOtherSettings().AdvancedCustom
return config != nil && config.SupportsPath(requestPath)
}
// getModelFromRequest 从请求中读取模型信息 // getModelFromRequest 从请求中读取模型信息
// 根据 Content-Type 自动处理: // 根据 Content-Type 自动处理:
// - application/json // - application/json
......
...@@ -7,6 +7,8 @@ import ( ...@@ -7,6 +7,8 @@ import (
"sync" "sync"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/samber/lo" "github.com/samber/lo"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -103,7 +105,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { ...@@ -103,7 +105,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
return channelQuery, nil return channelQuery, nil
} }
func GetChannel(group string, model string, retry int) (*Channel, error) { func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
var abilities []Ability var abilities []Ability
var err error = nil var err error = nil
...@@ -119,6 +121,7 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { ...@@ -119,6 +121,7 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
abilities = filterAbilitiesByRequestPath(abilities, requestPath)
channel := Channel{} channel := Channel{}
if len(abilities) > 0 { if len(abilities) > 0 {
// Randomly choose one // Randomly choose one
...@@ -143,6 +146,52 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { ...@@ -143,6 +146,52 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
return &channel, err return &channel, err
} }
// filterAbilitiesByRequestPath restricts candidates by request path for the DB
// (non-memory-cache) selection path. Only Advanced Custom (type 58) channels are
// path-checked: kept only when one of their routes matches requestPath; all other
// channel types always pass. When requestPath is empty, filtering is skipped.
func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Ability {
if requestPath == "" || len(abilities) == 0 {
return abilities
}
channelIds := make([]int, 0, len(abilities))
seen := make(map[int]struct{}, len(abilities))
for _, ability := range abilities {
if _, ok := seen[ability.ChannelId]; ok {
continue
}
seen[ability.ChannelId] = struct{}{}
channelIds = append(channelIds, ability.ChannelId)
}
var channels []*Channel
if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
// On error, fall back to unfiltered candidates to avoid blocking selection
return abilities
}
advancedConfigs := make(map[int]*dto.AdvancedCustomConfig)
for _, channel := range channels {
if channel.Type == constant.ChannelTypeAdvancedCustom {
advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom
}
}
filtered := make([]Ability, 0, len(abilities))
for _, ability := range abilities {
config, isAdvancedCustom := advancedConfigs[ability.ChannelId]
if !isAdvancedCustom {
filtered = append(filtered, ability)
continue
}
if config != nil && config.SupportsPath(requestPath) {
filtered = append(filtered, ability)
}
}
return filtered
}
func (channel *Channel) AddAbilities(tx *gorm.DB) error { func (channel *Channel) AddAbilities(tx *gorm.DB) error {
models_ := strings.Split(channel.Models, ",") models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",") groups_ := strings.Split(channel.Group, ",")
......
...@@ -956,9 +956,6 @@ func (channel *Channel) ValidateSettings() error { ...@@ -956,9 +956,6 @@ func (channel *Channel) ValidateSettings() error {
if channelOtherSettings.AdvancedCustom == nil { if channelOtherSettings.AdvancedCustom == nil {
return fmt.Errorf("advanced_custom is required") 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 channelOtherSettings.AdvancedCustom != nil {
if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil { if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil {
......
...@@ -11,12 +11,16 @@ import ( ...@@ -11,12 +11,16 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
) )
var group2model2channels map[string]map[string][]int // enabled channel var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled var channelsIDM map[int]*Channel // all channels include disabled
// channel2advancedCustomConfig caches parsed Advanced Custom (type 58) configs so
// path-aware selection avoids re-parsing JSON per request. Refreshed on full sync.
var channel2advancedCustomConfig map[int]*dto.AdvancedCustomConfig
var channelSyncLock sync.RWMutex var channelSyncLock sync.RWMutex
func InitChannelCache() { func InitChannelCache() {
...@@ -24,10 +28,16 @@ func InitChannelCache() { ...@@ -24,10 +28,16 @@ func InitChannelCache() {
return return
} }
newChannelId2channel := make(map[int]*Channel) newChannelId2channel := make(map[int]*Channel)
newChannel2advancedCustomConfig := make(map[int]*dto.AdvancedCustomConfig)
var channels []*Channel var channels []*Channel
DB.Find(&channels) DB.Find(&channels)
for _, channel := range channels { for _, channel := range channels {
newChannelId2channel[channel.Id] = channel newChannelId2channel[channel.Id] = channel
if channel.Type == constant.ChannelTypeAdvancedCustom {
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
newChannel2advancedCustomConfig[channel.Id] = config
}
}
} }
var abilities []*Ability var abilities []*Ability
DB.Find(&abilities) DB.Find(&abilities)
...@@ -82,6 +92,7 @@ func InitChannelCache() { ...@@ -82,6 +92,7 @@ func InitChannelCache() {
} }
} }
channelsIDM = newChannelId2channel channelsIDM = newChannelId2channel
channel2advancedCustomConfig = newChannel2advancedCustomConfig
channelSyncLock.Unlock() channelSyncLock.Unlock()
common.SysLog("channels synced from database") common.SysLog("channels synced from database")
} }
...@@ -94,22 +105,22 @@ func SyncChannelCache(frequency int) { ...@@ -94,22 +105,22 @@ func SyncChannelCache(frequency int) {
} }
} }
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
// if memory cache is disabled, get channel directly from database // if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled { if !common.MemoryCacheEnabled {
return GetChannel(group, model, retry) return GetChannel(group, model, retry, requestPath)
} }
channelSyncLock.RLock() channelSyncLock.RLock()
defer channelSyncLock.RUnlock() defer channelSyncLock.RUnlock()
// First, try to find channels with the exact model name. // First, try to find channels with the exact model name.
channels := group2model2channels[group][model] channels := filterChannelsByRequestPath(group2model2channels[group][model], requestPath)
// If no channels found, try to find channels with the normalized model name. // If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 { if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model) normalizedModel := ratio_setting.FormatMatchingModelName(model)
channels = group2model2channels[group][normalizedModel] channels = filterChannelsByRequestPath(group2model2channels[group][normalizedModel], requestPath)
} }
if len(channels) == 0 { if len(channels) == 0 {
...@@ -191,6 +202,34 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, ...@@ -191,6 +202,34 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
return nil, errors.New("channel not found") return nil, errors.New("channel not found")
} }
// filterChannelsByRequestPath restricts candidates by request path. Only Advanced
// Custom (type 58) channels are path-checked: they are kept only when one of their
// configured routes matches requestPath. All other channel types always pass.
// When requestPath is empty (non-relay callers) filtering is skipped.
// Caller must hold channelSyncLock (read lock). The cached slice is never mutated.
func filterChannelsByRequestPath(channels []int, requestPath string) []int {
if requestPath == "" || len(channels) == 0 {
return channels
}
filtered := make([]int, 0, len(channels))
for _, channelId := range channels {
channel, ok := channelsIDM[channelId]
if !ok {
// keep it so the downstream consistency error is raised as before
filtered = append(filtered, channelId)
continue
}
if channel.Type != constant.ChannelTypeAdvancedCustom {
filtered = append(filtered, channelId)
continue
}
if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPath(requestPath) {
filtered = append(filtered, channelId)
}
}
return filtered
}
func CacheGetChannel(id int) (*Channel, error) { func CacheGetChannel(id int) (*Channel, error) {
if !common.MemoryCacheEnabled { if !common.MemoryCacheEnabled {
return GetChannelById(id, true) return GetChannelById(id, true)
......
...@@ -32,7 +32,6 @@ type Adaptor struct { ...@@ -32,7 +32,6 @@ type Adaptor struct {
geminiAdaptor gemini.Adaptor geminiAdaptor gemini.Adaptor
resolved bool resolved bool
fallback bool
converted bool converted bool
route dto.AdvancedCustomRoute route dto.AdvancedCustomRoute
converter string converter string
...@@ -49,7 +48,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -49,7 +48,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil { if err != nil {
return nil, err return nil, err
} }
if a.fallback || converter == dto.AdvancedCustomConverterNone { if converter == dto.AdvancedCustomConverterNone {
return a.convertOpenAICompatibleRequest(c, info, request) return a.convertOpenAICompatibleRequest(c, info, request)
} }
...@@ -73,9 +72,6 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -73,9 +72,6 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil { if err != nil {
return nil, err return nil, err
} }
if a.fallback {
return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
}
switch converter { switch converter {
case dto.AdvancedCustomConverterNone: case dto.AdvancedCustomConverterNone:
...@@ -92,9 +88,6 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -92,9 +88,6 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil { if err != nil {
return nil, err return nil, err
} }
if a.fallback {
return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
}
switch converter { switch converter {
case dto.AdvancedCustomConverterNone: case dto.AdvancedCustomConverterNone:
...@@ -159,11 +152,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -159,11 +152,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if err := a.resolve(nil, info); err != nil { if err := a.resolve(nil, info); err != nil {
return "", err return "", err
} }
if a.fallback {
return a.withTemporaryChannelType(info, constant.ChannelTypeOpenAI, func() (string, error) {
return a.openaiAdaptor.GetRequestURL(info)
})
}
return a.routeURL(info) return a.routeURL(info)
} }
...@@ -171,13 +159,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info * ...@@ -171,13 +159,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
if err := a.resolve(c, info); err != nil { if err := a.resolve(c, info); err != nil {
return err 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) channel.SetupApiRequestHeader(info, c, header)
auth := a.route.Auth auth := a.route.Auth
...@@ -205,7 +186,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request ...@@ -205,7 +186,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
if err := a.resolve(c, info); err != nil { if err := a.resolve(c, info); err != nil {
return nil, err return nil, err
} }
if !a.converted && (a.fallback || a.converter != dto.AdvancedCustomConverterNone) { if !a.converted && a.converter != dto.AdvancedCustomConverterNone {
return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body") return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body")
} }
...@@ -224,9 +205,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom ...@@ -224,9 +205,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if err := a.resolve(c, info); err != nil { if err := a.resolve(c, info); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) return nil, types.NewOpenAIError(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
} }
if a.fallback {
return a.openaiAdaptor.DoResponse(c, resp, info)
}
switch a.converter { switch a.converter {
case dto.AdvancedCustomConverterNone: case dto.AdvancedCustomConverterNone:
...@@ -295,9 +273,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error { ...@@ -295,9 +273,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
} }
incomingPath := incomingRequestPath(c, info) incomingPath := incomingRequestPath(c, info)
route, ok := lo.Find(config.Routes, func(route dto.AdvancedCustomRoute) bool { route, ok := config.MatchPath(incomingPath)
return matchIncomingPath(strings.TrimSpace(route.IncomingPath), incomingPath)
})
if ok { if ok {
route.Converter = strings.TrimSpace(route.Converter) route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" { if route.Converter == "" {
...@@ -308,13 +284,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error { ...@@ -308,13 +284,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
a.resolved = true a.resolved = true
return nil return nil
} }
if config.Fallback.Enabled { return fmt.Errorf("advanced custom channel does not support request path: %s", incomingPath)
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 { func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
...@@ -327,34 +297,6 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string { ...@@ -327,34 +297,6 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
return strings.Split(info.RequestURLPath, "?")[0] 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) { func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info) parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info)
if err != nil { if err != nil {
...@@ -535,11 +477,3 @@ func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *rela ...@@ -535,11 +477,3 @@ func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *rela
info.ChannelType = old info.ChannelType = old
return converted, err 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
}
...@@ -161,7 +161,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) { ...@@ -161,7 +161,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) {
assert.Equal(t, "2023-06-01", header.Get("anthropic-version")) assert.Equal(t, "2023-06-01", header.Get("anthropic-version"))
} }
func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) { func TestAdaptorReturnsErrorWhenNoRouteMatchesPath(t *testing.T) {
adaptor := &Adaptor{} adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{ Routes: []dto.AdvancedCustomRoute{
...@@ -176,20 +176,7 @@ func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) { ...@@ -176,20 +176,7 @@ func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) {
_, err := adaptor.GetRequestURL(info) _, err := adaptor.GetRequestURL(info)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "route not found") assert.Contains(t, err.Error(), "does not support request path")
}
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) { func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) {
......
...@@ -15,6 +15,7 @@ type RetryParam struct { ...@@ -15,6 +15,7 @@ type RetryParam struct {
Ctx *gin.Context Ctx *gin.Context
TokenGroup string TokenGroup string
ModelName string ModelName string
RequestPath string
Retry *int Retry *int
resetNextTry bool resetNextTry bool
} }
...@@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, ...@@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
} }
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath)
if channel == nil { if channel == nil {
// Current group has no available channel for this model, try next group // Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组 // 当前分组没有该模型的可用渠道,尝试下一个分组
...@@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, ...@@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break break
} }
} else { } else {
channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath)
if err != nil { if err != nil {
return nil, param.TokenGroup, err return nil, param.TokenGroup, err
} }
......
...@@ -20,6 +20,8 @@ import * as React from 'react' ...@@ -20,6 +20,8 @@ import * as React from 'react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Combobox, Combobox,
...@@ -64,6 +66,11 @@ interface MultiSelectProps { ...@@ -64,6 +66,11 @@ interface MultiSelectProps {
* Hidden values remain searchable/removable from the dropdown. * Hidden values remain searchable/removable from the dropdown.
*/ */
maxVisibleChips?: number maxVisibleChips?: number
/**
* When true, clicking a chip's label copies its value to the clipboard
* instead of being inert. The remove (×) button keeps its own behaviour.
*/
copyChipOnClick?: boolean
} }
const COMMA_REGEX = /[,,\n]/ const COMMA_REGEX = /[,,\n]/
...@@ -109,6 +116,7 @@ export function MultiSelect(props: MultiSelectProps) { ...@@ -109,6 +116,7 @@ export function MultiSelect(props: MultiSelectProps) {
const [inputValue, setInputValue] = React.useState('') const [inputValue, setInputValue] = React.useState('')
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const [expanded, setExpanded] = React.useState(false)
const selectedSet = React.useMemo( const selectedSet = React.useMemo(
() => new Set(props.selected), () => new Set(props.selected),
...@@ -195,6 +203,25 @@ export function MultiSelect(props: MultiSelectProps) { ...@@ -195,6 +203,25 @@ export function MultiSelect(props: MultiSelectProps) {
} }
} }
const handleCopyChip = React.useCallback(
async (
event: React.MouseEvent<HTMLButtonElement>,
value: string,
label: string
) => {
// Prevent the click from toggling the combobox popup or focusing input.
event.preventDefault()
event.stopPropagation()
const ok = await copyToClipboard(value)
if (ok) {
toast.success(t('Copied: {{model}}', { model: label }))
} else {
toast.error(t('Failed to copy'))
}
},
[t]
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Enter without a highlighted option commits the typed value. // Enter without a highlighted option commits the typed value.
if (event.key === 'Enter' && props.allowCreate && canCreate) { if (event.key === 'Enter' && props.allowCreate && canCreate) {
...@@ -231,26 +258,69 @@ export function MultiSelect(props: MultiSelectProps) { ...@@ -231,26 +258,69 @@ export function MultiSelect(props: MultiSelectProps) {
> >
<ComboboxValue> <ComboboxValue>
{(values: string[]) => { {(values: string[]) => {
const visibleValues = const shouldLimit =
typeof props.maxVisibleChips === 'number' typeof props.maxVisibleChips === 'number' && !expanded
? values.slice(0, props.maxVisibleChips) const visibleValues = shouldLimit
: values ? values.slice(0, props.maxVisibleChips)
: values
const hiddenCount = values.length - visibleValues.length const hiddenCount = values.length - visibleValues.length
return ( return (
<> <>
{visibleValues.map((value) => ( {visibleValues.map((value) => {
<ComboboxChip key={value}> const label = labelMap.get(value) ?? value
<span className='max-w-[16rem] truncate'> return (
{labelMap.get(value) ?? value} <ComboboxChip key={value}>
</span> {props.copyChipOnClick ? (
</ComboboxChip> <button
))} type='button'
onClick={(event) =>
handleCopyChip(event, value, label)
}
onPointerDown={(event) => event.stopPropagation()}
title={t('Click to copy')}
className='max-w-[16rem] cursor-pointer truncate rounded-sm hover:underline'
>
{label}
</button>
) : (
<span className='max-w-[16rem] truncate'>{label}</span>
)}
</ComboboxChip>
)
})}
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<span className='bg-muted text-muted-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap'> <button
type='button'
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setExpanded(true)
}}
onPointerDown={(event) => event.stopPropagation()}
title={t('Show All')}
className='bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground flex h-[calc(--spacing(5.25))] w-fit cursor-pointer items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap transition-colors'
>
{t('+{{count}} more', { count: hiddenCount })} {t('+{{count}} more', { count: hiddenCount })}
</span> </button>
)} )}
{expanded &&
typeof props.maxVisibleChips === 'number' &&
values.length > props.maxVisibleChips && (
<button
type='button'
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setExpanded(false)
}}
onPointerDown={(event) => event.stopPropagation()}
title={t('Collapse')}
className='bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground flex h-[calc(--spacing(5.25))] w-fit cursor-pointer items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap transition-colors'
>
{t('Collapse')}
</button>
)}
</> </>
) )
}} }}
......
...@@ -33,7 +33,6 @@ import { ...@@ -33,7 +33,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { import {
...@@ -152,13 +151,6 @@ export function AdvancedCustomEditorDialog({ ...@@ -152,13 +151,6 @@ export function AdvancedCustomEditorDialog({
}) })
} }
const setFallbackEnabled = (enabled: boolean) => {
setConfig((current) => ({
...normalizeAdvancedCustomConfig(current),
advanced_fallback: { enabled },
}))
}
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => { const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
const parsed = parseAdvancedCustomConfig(jsonText) const parsed = parseAdvancedCustomConfig(jsonText)
if (!parsed) { if (!parsed) {
...@@ -215,11 +207,6 @@ export function AdvancedCustomEditorDialog({ ...@@ -215,11 +207,6 @@ export function AdvancedCustomEditorDialog({
...(base.advanced_routes || []), ...(base.advanced_routes || []),
...(template.advanced_routes || []), ...(template.advanced_routes || []),
], ],
advanced_fallback: {
enabled:
base.advanced_fallback?.enabled === true ||
template.advanced_fallback?.enabled === true,
},
} }
} }
...@@ -355,23 +342,7 @@ export function AdvancedCustomEditorDialog({ ...@@ -355,23 +342,7 @@ export function AdvancedCustomEditorDialog({
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='flex flex-col gap-5 p-4'> <div className='flex flex-col gap-5 p-4'>
<div className='flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between'> <div className='flex justify-end border-y py-4'>
<div className='flex items-center gap-3'>
<Switch
checked={normalizedConfig.advanced_fallback?.enabled === true}
onCheckedChange={setFallbackEnabled}
/>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{t('Fallback routing')}
</div>
<div className='text-muted-foreground max-w-2xl text-xs leading-relaxed'>
{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.'
)}
</div>
</div>
</div>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
......
...@@ -1845,18 +1845,6 @@ export function ChannelMutateDrawer({ ...@@ -1845,18 +1845,6 @@ export function ChannelMutateDrawer({
{t('Routes')}:{' '} {t('Routes')}:{' '}
{advancedCustomStats.routeCount} {advancedCustomStats.routeCount}
</Badge> </Badge>
<Badge
variant={
advancedCustomStats.fallbackEnabled
? 'default'
: 'outline'
}
>
{t('Fallback')}:{' '}
{advancedCustomStats.fallbackEnabled
? t('Enabled')
: t('Disabled')}
</Badge>
{!advancedCustomStats.valid && ( {!advancedCustomStats.valid && (
<Badge variant='destructive'> <Badge variant='destructive'>
{t('Incomplete')} {t('Incomplete')}
...@@ -2254,6 +2242,7 @@ export function ChannelMutateDrawer({ ...@@ -2254,6 +2242,7 @@ export function ChannelMutateDrawer({
allowCreate allowCreate
createLabel='Add custom model "{{value}}"' createLabel='Add custom model "{{value}}"'
maxVisibleChips={8} maxVisibleChips={8}
copyChipOnClick
/> />
</FormControl> </FormControl>
{modelMappingGuardrail.exposedTargetModels {modelMappingGuardrail.exposedTargetModels
......
...@@ -28,14 +28,14 @@ export const CHANNEL_TYPES = { ...@@ -28,14 +28,14 @@ export const CHANNEL_TYPES = {
3: 'Azure', 3: 'Azure',
4: 'Ollama', 4: 'Ollama',
5: 'MidjourneyPlus', 5: 'MidjourneyPlus',
6: 'OpenAIMax', // 6: 'OpenAIMax',
7: 'OhMyGPT', 7: 'OhMyGPT',
8: 'Custom', 8: 'Custom',
9: 'AILS', // 9: 'AILS',
10: 'AI Proxy', // 10: 'AI Proxy',
11: 'PaLM', // 11: 'PaLM',
12: 'API2GPT', // 12: 'API2GPT',
13: 'AIGC2D', // 13: 'AIGC2D',
14: 'Anthropic', 14: 'Anthropic',
15: 'Baidu', 15: 'Baidu',
16: 'Zhipu', 16: 'Zhipu',
...@@ -43,7 +43,7 @@ export const CHANNEL_TYPES = { ...@@ -43,7 +43,7 @@ export const CHANNEL_TYPES = {
18: 'Xunfei', 18: 'Xunfei',
19: '360', 19: '360',
20: 'OpenRouter', 20: 'OpenRouter',
21: 'AI Proxy Library', // 21: 'AI Proxy Library',
22: 'FastGPT', 22: 'FastGPT',
23: 'Tencent', 23: 'Tencent',
24: 'Gemini', 24: 'Gemini',
...@@ -80,8 +80,8 @@ export const CHANNEL_TYPES = { ...@@ -80,8 +80,8 @@ export const CHANNEL_TYPES = {
} as const } as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23, 1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 58, 57, 22, 21, 44, 2, 5, 36, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56, 50, 51, 52, 53, 54, 55, 56,
] ]
......
...@@ -176,12 +176,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -176,12 +176,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/chat/completions', incoming_path: '/v1/chat/completions',
upstream_path: 'https://api.openai.com/v1/chat/completions', upstream_path: '/v1/chat/completions',
converter: 'none', converter: 'none',
auth: bearerHeaderAuth(), auth: bearerHeaderAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -191,12 +190,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -191,12 +190,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/responses', incoming_path: '/v1/responses',
upstream_path: 'https://api.openai.com/v1/responses', upstream_path: '/v1/responses',
converter: 'none', converter: 'none',
auth: bearerHeaderAuth(), auth: bearerHeaderAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -206,12 +204,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -206,12 +204,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/embeddings', incoming_path: '/v1/embeddings',
upstream_path: 'https://api.openai.com/v1/embeddings', upstream_path: '/v1/embeddings',
converter: 'none', converter: 'none',
auth: bearerHeaderAuth(), auth: bearerHeaderAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -221,18 +218,17 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -221,18 +218,17 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/images/generations', incoming_path: '/v1/images/generations',
upstream_path: 'https://api.openai.com/v1/images/generations', upstream_path: '/v1/images/generations',
converter: 'none', converter: 'none',
auth: bearerHeaderAuth(), auth: bearerHeaderAuth(),
}, },
{ {
incoming_path: '/v1/images/edits', incoming_path: '/v1/images/edits',
upstream_path: 'https://api.openai.com/v1/images/edits', upstream_path: '/v1/images/edits',
converter: 'none', converter: 'none',
auth: bearerHeaderAuth(), auth: bearerHeaderAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -242,12 +238,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -242,12 +238,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/messages', incoming_path: '/v1/messages',
upstream_path: 'https://api.anthropic.com/v1/messages', upstream_path: '/v1/messages',
converter: 'none', converter: 'none',
auth: apiKeyHeaderAuth(), auth: apiKeyHeaderAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -257,27 +252,23 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -257,27 +252,23 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1beta/models/{model}:generateContent', incoming_path: '/v1beta/models/{model}:generateContent',
upstream_path: upstream_path: '/v1beta/models/{model}:generateContent',
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
converter: 'none', converter: 'none',
auth: geminiQueryAuth(), auth: geminiQueryAuth(),
}, },
{ {
incoming_path: '/v1beta/models/{model}:embedContent', incoming_path: '/v1beta/models/{model}:embedContent',
upstream_path: upstream_path: '/v1beta/models/{model}:embedContent',
'https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent',
converter: 'none', converter: 'none',
auth: geminiQueryAuth(), auth: geminiQueryAuth(),
}, },
{ {
incoming_path: '/v1beta/models/{model}:batchEmbedContents', incoming_path: '/v1beta/models/{model}:batchEmbedContents',
upstream_path: upstream_path: '/v1beta/models/{model}:batchEmbedContents',
'https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents',
converter: 'none', converter: 'none',
auth: geminiQueryAuth(), auth: geminiQueryAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
{ {
...@@ -287,13 +278,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] = ...@@ -287,13 +278,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [ advanced_routes: [
{ {
incoming_path: '/v1/chat/completions', incoming_path: '/v1/chat/completions',
upstream_path: upstream_path: '/v1beta/models/{model}:generateContent',
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
converter: 'openai_chat_completions_to_gemini_generate_content', converter: 'openai_chat_completions_to_gemini_generate_content',
auth: geminiQueryAuth(), auth: geminiQueryAuth(),
}, },
], ],
advanced_fallback: { enabled: false },
}, },
}, },
] ]
...@@ -325,7 +314,6 @@ export function createAdvancedCustomRoute(): AdvancedCustomRoute { ...@@ -325,7 +314,6 @@ export function createAdvancedCustomRoute(): AdvancedCustomRoute {
export function createAdvancedCustomConfig(): AdvancedCustomConfig { export function createAdvancedCustomConfig(): AdvancedCustomConfig {
return { return {
advanced_routes: [createAdvancedCustomRoute()], advanced_routes: [createAdvancedCustomRoute()],
advanced_fallback: { enabled: false },
} }
} }
...@@ -405,9 +393,6 @@ export function normalizeAdvancedCustomConfig( ...@@ -405,9 +393,6 @@ export function normalizeAdvancedCustomConfig(
return { return {
advanced_routes: routes, advanced_routes: routes,
advanced_fallback: {
enabled: config.advanced_fallback?.enabled === true,
},
} }
} }
...@@ -420,11 +405,9 @@ export function validateAdvancedCustomConfig( ...@@ -420,11 +405,9 @@ export function validateAdvancedCustomConfig(
const normalized = normalizeAdvancedCustomConfig(config) const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || [] const routes = normalized.advanced_routes || []
const fallbackEnabled = normalized.advanced_fallback?.enabled === true if (routes.length === 0) {
if (routes.length === 0 && !fallbackEnabled) {
return { return {
message: message: 'Advanced custom configuration requires at least one route',
'Advanced custom configuration requires at least one route or fallback',
} }
} }
...@@ -492,17 +475,15 @@ export function advancedCustomConfigUsesRelativeUpstreamPath( ...@@ -492,17 +475,15 @@ export function advancedCustomConfigUsesRelativeUpstreamPath(
export function getAdvancedCustomStats(value: string | undefined): { export function getAdvancedCustomStats(value: string | undefined): {
routeCount: number routeCount: number
fallbackEnabled: boolean
valid: boolean valid: boolean
} { } {
const config = parseAdvancedCustomConfig(value) const config = parseAdvancedCustomConfig(value)
if (!config) { if (!config) {
return { routeCount: 0, fallbackEnabled: false, valid: false } return { routeCount: 0, valid: false }
} }
const normalized = normalizeAdvancedCustomConfig(config) const normalized = normalizeAdvancedCustomConfig(config)
return { return {
routeCount: normalized.advanced_routes?.length || 0, routeCount: normalized.advanced_routes?.length || 0,
fallbackEnabled: normalized.advanced_fallback?.enabled === true,
valid: validateAdvancedCustomConfig(normalized) === null, valid: validateAdvancedCustomConfig(normalized) === null,
} }
} }
......
...@@ -227,16 +227,6 @@ export const channelFormSchema = z ...@@ -227,16 +227,6 @@ export const channelFormSchema = z
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message) addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
} }
if ( if (
advancedCustomConfig?.advanced_fallback?.enabled === true &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when fallback is enabled'
)
}
if (
advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) && advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) &&
!data.base_url?.trim() !data.base_url?.trim()
) { ) {
......
...@@ -110,7 +110,6 @@ export interface ChannelOtherSettings { ...@@ -110,7 +110,6 @@ export interface ChannelOtherSettings {
export interface AdvancedCustomConfig { export interface AdvancedCustomConfig {
advanced_routes?: AdvancedCustomRoute[] advanced_routes?: AdvancedCustomRoute[]
advanced_fallback?: AdvancedCustomFallback
} }
export interface AdvancedCustomRoute { export interface AdvancedCustomRoute {
...@@ -120,10 +119,6 @@ export interface AdvancedCustomRoute { ...@@ -120,10 +119,6 @@ export interface AdvancedCustomRoute {
auth?: AdvancedCustomRouteAuth auth?: AdvancedCustomRouteAuth
} }
export interface AdvancedCustomFallback {
enabled?: boolean
}
export interface AdvancedCustomRouteAuth { export interface AdvancedCustomRouteAuth {
type?: AdvancedCustomAuthType type?: AdvancedCustomAuthType
name?: string name?: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment