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) {
}()
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0),
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
}
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
......@@ -507,10 +508,11 @@ func RelayTask(c *gin.Context) {
}()
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0),
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
......
......@@ -73,8 +73,7 @@ const (
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"`
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
}
type AdvancedCustomRoute struct {
......@@ -84,16 +83,63 @@ type AdvancedCustomRoute struct {
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"`
}
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 {
switch converter {
case AdvancedCustomConverterNone,
......@@ -112,8 +158,8 @@ 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")
if len(c.Routes) == 0 {
return fmt.Errorf("advanced_custom requires at least one route")
}
seenPaths := make(map[string]struct{}, len(c.Routes))
......
......@@ -104,7 +104,8 @@ func Distribute() func(c *gin.Context) {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false
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" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
......@@ -132,10 +133,11 @@ func Distribute() func(c *gin.Context) {
if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
Retry: common.GetPointer(0),
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
})
if err != nil {
showGroup := usingGroup
......@@ -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 从请求中读取模型信息
// 根据 Content-Type 自动处理:
// - application/json
......
......@@ -7,6 +7,8 @@ import (
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/samber/lo"
"gorm.io/gorm"
......@@ -103,7 +105,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
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 err error = nil
......@@ -119,6 +121,7 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
if err != nil {
return nil, err
}
abilities = filterAbilitiesByRequestPath(abilities, requestPath)
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
......@@ -143,6 +146,52 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
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 {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
......
......@@ -956,9 +956,6 @@ func (channel *Channel) ValidateSettings() error {
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 {
......
......@@ -11,12 +11,16 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
var group2model2channels map[string]map[string][]int // enabled channel
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
func InitChannelCache() {
......@@ -24,10 +28,16 @@ func InitChannelCache() {
return
}
newChannelId2channel := make(map[int]*Channel)
newChannel2advancedCustomConfig := make(map[int]*dto.AdvancedCustomConfig)
var channels []*Channel
DB.Find(&channels)
for _, channel := range channels {
newChannelId2channel[channel.Id] = channel
if channel.Type == constant.ChannelTypeAdvancedCustom {
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
newChannel2advancedCustomConfig[channel.Id] = config
}
}
}
var abilities []*Ability
DB.Find(&abilities)
......@@ -82,6 +92,7 @@ func InitChannelCache() {
}
}
channelsIDM = newChannelId2channel
channel2advancedCustomConfig = newChannel2advancedCustomConfig
channelSyncLock.Unlock()
common.SysLog("channels synced from database")
}
......@@ -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 !common.MemoryCacheEnabled {
return GetChannel(group, model, retry)
return GetChannel(group, model, retry, requestPath)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
// 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 len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
channels = group2model2channels[group][normalizedModel]
channels = filterChannelsByRequestPath(group2model2channels[group][normalizedModel], requestPath)
}
if len(channels) == 0 {
......@@ -191,6 +202,34 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
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) {
if !common.MemoryCacheEnabled {
return GetChannelById(id, true)
......
......@@ -32,7 +32,6 @@ type Adaptor struct {
geminiAdaptor gemini.Adaptor
resolved bool
fallback bool
converted bool
route dto.AdvancedCustomRoute
converter string
......@@ -49,7 +48,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil {
return nil, err
}
if a.fallback || converter == dto.AdvancedCustomConverterNone {
if converter == dto.AdvancedCustomConverterNone {
return a.convertOpenAICompatibleRequest(c, info, request)
}
......@@ -73,9 +72,6 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil {
return nil, err
}
if a.fallback {
return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
}
switch converter {
case dto.AdvancedCustomConverterNone:
......@@ -92,9 +88,6 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil {
return nil, err
}
if a.fallback {
return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
}
switch converter {
case dto.AdvancedCustomConverterNone:
......@@ -159,11 +152,6 @@ 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)
}
......@@ -171,13 +159,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
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
......@@ -205,7 +186,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
if err := a.resolve(c, info); err != nil {
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")
}
......@@ -224,9 +205,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
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:
......@@ -295,9 +273,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
}
incomingPath := incomingRequestPath(c, info)
route, ok := lo.Find(config.Routes, func(route dto.AdvancedCustomRoute) bool {
return matchIncomingPath(strings.TrimSpace(route.IncomingPath), incomingPath)
})
route, ok := config.MatchPath(incomingPath)
if ok {
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
......@@ -308,13 +284,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
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)
return fmt.Errorf("advanced custom channel does not support request path: %s", incomingPath)
}
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]
}
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 {
......@@ -535,11 +477,3 @@ func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *rela
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
}
......@@ -161,7 +161,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) {
assert.Equal(t, "2023-06-01", header.Get("anthropic-version"))
}
func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) {
func TestAdaptorReturnsErrorWhenNoRouteMatchesPath(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
......@@ -176,20 +176,7 @@ func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) {
_, 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)
assert.Contains(t, err.Error(), "does not support request path")
}
func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) {
......
......@@ -15,6 +15,7 @@ type RetryParam struct {
Ctx *gin.Context
TokenGroup string
ModelName string
RequestPath string
Retry *int
resetNextTry bool
}
......@@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
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 {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
......@@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} 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 {
return nil, param.TokenGroup, err
}
......
......@@ -20,6 +20,8 @@ import * as React from 'react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { cn } from '@/lib/utils'
import {
Combobox,
......@@ -64,6 +66,11 @@ interface MultiSelectProps {
* Hidden values remain searchable/removable from the dropdown.
*/
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]/
......@@ -109,6 +116,7 @@ export function MultiSelect(props: MultiSelectProps) {
const [inputValue, setInputValue] = React.useState('')
const [open, setOpen] = React.useState(false)
const [expanded, setExpanded] = React.useState(false)
const selectedSet = React.useMemo(
() => new Set(props.selected),
......@@ -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>) => {
// Enter without a highlighted option commits the typed value.
if (event.key === 'Enter' && props.allowCreate && canCreate) {
......@@ -231,26 +258,69 @@ export function MultiSelect(props: MultiSelectProps) {
>
<ComboboxValue>
{(values: string[]) => {
const visibleValues =
typeof props.maxVisibleChips === 'number'
? values.slice(0, props.maxVisibleChips)
: values
const shouldLimit =
typeof props.maxVisibleChips === 'number' && !expanded
const visibleValues = shouldLimit
? values.slice(0, props.maxVisibleChips)
: values
const hiddenCount = values.length - visibleValues.length
return (
<>
{visibleValues.map((value) => (
<ComboboxChip key={value}>
<span className='max-w-[16rem] truncate'>
{labelMap.get(value) ?? value}
</span>
</ComboboxChip>
))}
{visibleValues.map((value) => {
const label = labelMap.get(value) ?? value
return (
<ComboboxChip key={value}>
{props.copyChipOnClick ? (
<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 && (
<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 })}
</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 {
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 {
......@@ -152,13 +151,6 @@ export function AdvancedCustomEditorDialog({
})
}
const setFallbackEnabled = (enabled: boolean) => {
setConfig((current) => ({
...normalizeAdvancedCustomConfig(current),
advanced_fallback: { enabled },
}))
}
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
const parsed = parseAdvancedCustomConfig(jsonText)
if (!parsed) {
......@@ -215,11 +207,6 @@ export function AdvancedCustomEditorDialog({
...(base.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({
{editMode === 'visual' ? (
<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 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>
<div className='flex justify-end border-y py-4'>
<Button
type='button'
variant='outline'
......
......@@ -1845,18 +1845,6 @@ export function ChannelMutateDrawer({
{t('Routes')}:{' '}
{advancedCustomStats.routeCount}
</Badge>
<Badge
variant={
advancedCustomStats.fallbackEnabled
? 'default'
: 'outline'
}
>
{t('Fallback')}:{' '}
{advancedCustomStats.fallbackEnabled
? t('Enabled')
: t('Disabled')}
</Badge>
{!advancedCustomStats.valid && (
<Badge variant='destructive'>
{t('Incomplete')}
......@@ -2254,6 +2242,7 @@ export function ChannelMutateDrawer({
allowCreate
createLabel='Add custom model "{{value}}"'
maxVisibleChips={8}
copyChipOnClick
/>
</FormControl>
{modelMappingGuardrail.exposedTargetModels
......
......@@ -28,14 +28,14 @@ export const CHANNEL_TYPES = {
3: 'Azure',
4: 'Ollama',
5: 'MidjourneyPlus',
6: 'OpenAIMax',
// 6: 'OpenAIMax',
7: 'OhMyGPT',
8: 'Custom',
9: 'AILS',
10: 'AI Proxy',
11: 'PaLM',
12: 'API2GPT',
13: 'AIGC2D',
// 9: 'AILS',
// 10: 'AI Proxy',
// 11: 'PaLM',
// 12: 'API2GPT',
// 13: 'AIGC2D',
14: 'Anthropic',
15: 'Baidu',
16: 'Zhipu',
......@@ -43,7 +43,7 @@ export const CHANNEL_TYPES = {
18: 'Xunfei',
19: '360',
20: 'OpenRouter',
21: 'AI Proxy Library',
// 21: 'AI Proxy Library',
22: 'FastGPT',
23: 'Tencent',
24: 'Gemini',
......@@ -80,8 +80,8 @@ export const CHANNEL_TYPES = {
} as const
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,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 58, 57, 22, 21, 44, 2, 5, 36,
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, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56,
]
......
......@@ -176,12 +176,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: 'https://api.openai.com/v1/chat/completions',
upstream_path: '/v1/chat/completions',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -191,12 +190,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/responses',
upstream_path: 'https://api.openai.com/v1/responses',
upstream_path: '/v1/responses',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -206,12 +204,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/embeddings',
upstream_path: 'https://api.openai.com/v1/embeddings',
upstream_path: '/v1/embeddings',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -221,18 +218,17 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/images/generations',
upstream_path: 'https://api.openai.com/v1/images/generations',
upstream_path: '/v1/images/generations',
converter: 'none',
auth: bearerHeaderAuth(),
},
{
incoming_path: '/v1/images/edits',
upstream_path: 'https://api.openai.com/v1/images/edits',
upstream_path: '/v1/images/edits',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -242,12 +238,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/messages',
upstream_path: 'https://api.anthropic.com/v1/messages',
upstream_path: '/v1/messages',
converter: 'none',
auth: apiKeyHeaderAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -257,27 +252,23 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1beta/models/{model}:generateContent',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:embedContent',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent',
upstream_path: '/v1beta/models/{model}:embedContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents',
upstream_path: '/v1beta/models/{model}:batchEmbedContents',
converter: 'none',
auth: geminiQueryAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
{
......@@ -287,13 +278,11 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path:
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'openai_chat_completions_to_gemini_generate_content',
auth: geminiQueryAuth(),
},
],
advanced_fallback: { enabled: false },
},
},
]
......@@ -325,7 +314,6 @@ export function createAdvancedCustomRoute(): AdvancedCustomRoute {
export function createAdvancedCustomConfig(): AdvancedCustomConfig {
return {
advanced_routes: [createAdvancedCustomRoute()],
advanced_fallback: { enabled: false },
}
}
......@@ -405,9 +393,6 @@ export function normalizeAdvancedCustomConfig(
return {
advanced_routes: routes,
advanced_fallback: {
enabled: config.advanced_fallback?.enabled === true,
},
}
}
......@@ -420,11 +405,9 @@ export function validateAdvancedCustomConfig(
const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || []
const fallbackEnabled = normalized.advanced_fallback?.enabled === true
if (routes.length === 0 && !fallbackEnabled) {
if (routes.length === 0) {
return {
message:
'Advanced custom configuration requires at least one route or fallback',
message: 'Advanced custom configuration requires at least one route',
}
}
......@@ -492,17 +475,15 @@ export function advancedCustomConfigUsesRelativeUpstreamPath(
export function getAdvancedCustomStats(value: string | undefined): {
routeCount: number
fallbackEnabled: boolean
valid: boolean
} {
const config = parseAdvancedCustomConfig(value)
if (!config) {
return { routeCount: 0, fallbackEnabled: false, valid: false }
return { routeCount: 0, valid: false }
}
const normalized = normalizeAdvancedCustomConfig(config)
return {
routeCount: normalized.advanced_routes?.length || 0,
fallbackEnabled: normalized.advanced_fallback?.enabled === true,
valid: validateAdvancedCustomConfig(normalized) === null,
}
}
......
......@@ -227,16 +227,6 @@ export const channelFormSchema = z
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
}
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) &&
!data.base_url?.trim()
) {
......
......@@ -110,7 +110,6 @@ export interface ChannelOtherSettings {
export interface AdvancedCustomConfig {
advanced_routes?: AdvancedCustomRoute[]
advanced_fallback?: AdvancedCustomFallback
}
export interface AdvancedCustomRoute {
......@@ -120,10 +119,6 @@ export interface AdvancedCustomRoute {
auth?: AdvancedCustomRouteAuth
}
export interface AdvancedCustomFallback {
enabled?: boolean
}
export interface AdvancedCustomRouteAuth {
type?: AdvancedCustomAuthType
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