Просмотр исходного кода

feat(token): support dynamic channel specification via colon separator

- Add support for sk-{key}:{channelId} format to specify channel dynamically
- Remove BoundChannelId field and related code
- All users can now specify channels, not just admins
- Add model validation when channel is specified
- Remove bound channel UI from token management

Breaking change: tokens can no longer be bound to channels at creation time.
Use sk-{key}:{channelId} format instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 месяц назад
Родитель
Сommit
bfa7cc7b97
6 измененных файлов: 101 добавлений и 130 удалений
  1. +0
    -1
      constant/context_key.go
  2. +0
    -2
      controller/token.go
  3. +22
    -10
      middleware/auth.go
  4. +78
    -103
      middleware/distributor.go
  5. +1
    -2
      model/token.go
  6. +0
    -12
      web/src/components/table/tokens/modals/EditTokenModal.jsx

+ 0
- 1
constant/context_key.go Просмотреть файл

@@ -19,7 +19,6 @@ const (
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
ContextKeyTokenBoundChannelId ContextKey = "token_bound_channel_id"

/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"


+ 0
- 2
controller/token.go Просмотреть файл

@@ -194,7 +194,6 @@ func AddToken(c *gin.Context) {
AllowIps: token.AllowIps,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
BoundChannelId: token.BoundChannelId,
}
err = cleanToken.Insert()
if err != nil {
@@ -275,7 +274,6 @@ func UpdateToken(c *gin.Context) {
cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
cleanToken.BoundChannelId = token.BoundChannelId
}
err = cleanToken.Update()
if err != nil {


+ 22
- 10
middleware/auth.go Просмотреть файл

@@ -294,12 +294,32 @@ func TokenAuth() func(c *gin.Context) {
key = strings.TrimSpace(key[7:])
}
key = strings.TrimPrefix(key, "sk-")
// 先按冒号分割获取 channelId(新格式:sk-{key}:{channelId})
var colonChannelId string
if colonIdx := strings.Index(key, ":"); colonIdx != -1 {
colonChannelId = key[colonIdx+1:]
key = key[:colonIdx]
}
parts = strings.Split(key, "-")
key = parts[0]
// 如果冒号方式指定了渠道,追加到 parts
if colonChannelId != "" {
parts = append(parts, colonChannelId)
}
} else {
key = strings.TrimPrefix(key, "sk-")
// 先按冒号分割获取 channelId(新格式:sk-{key}:{channelId})
var colonChannelId string
if colonIdx := strings.Index(key, ":"); colonIdx != -1 {
colonChannelId = key[colonIdx+1:]
key = key[:colonIdx]
}
parts = strings.Split(key, "-")
key = parts[0]
// 如果冒号方式指定了渠道,追加到 parts
if colonChannelId != "" {
parts = append(parts, colonChannelId)
}
}
token, err := model.ValidateUserToken(key)
if token != nil {
@@ -389,17 +409,9 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
if token.BoundChannelId != nil {
common.SetContextKey(c, constant.ContextKeyTokenBoundChannelId, *token.BoundChannelId)
}
// 所有用户都可以通过 sk-{key}:{channelId} 或 sk-{key}-{channelId} 指定渠道
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1])
} else {
c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8")
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
return fmt.Errorf("普通用户不支持指定渠道")
}
c.Set("specific_channel_id", parts[1])
}
return nil
}

+ 78
- 103
middleware/distributor.go Просмотреть файл

@@ -51,129 +51,104 @@ func Distribute() func(c *gin.Context) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
return
}
// 验证指定渠道是否支持请求的模型
if shouldSelectChannel && modelRequest.Model != "" {
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
if !model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, channel.Id) {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
return
}
}
} else {
// Select a channel for the user
// Check if token has a bound channel
boundChannelId, hasBoundChannel := common.GetContextKey(c, constant.ContextKeyTokenBoundChannelId)
if hasBoundChannel {
id, ok := boundChannelId.(int)
// Normal channel selection logic
// check token model mapping
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
if !ok {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
// token model limit is empty, all models are not allowed
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenNoModelAccess))
return
}
channel, err = model.GetChannelById(id, true)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
return
var tokenModelLimit map[string]bool
tokenModelLimit, ok = s.(map[string]bool)
if !ok {
tokenModelLimit = map[string]bool{}
}
if channel.Status != common.ChannelStatusEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
if _, ok := tokenModelLimit[matchName]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model}))
return
}
// Verify the bound channel supports the requested model
if shouldSelectChannel && modelRequest.Model != "" {
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
if !model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, channel.Id) {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
return
}
}
} else {
// Normal channel selection logic
// check token model mapping
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
if !ok {
// token model limit is empty, all models are not allowed
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenNoModelAccess))
return
}
var tokenModelLimit map[string]bool
tokenModelLimit, ok = s.(map[string]bool)
if !ok {
tokenModelLimit = map[string]bool{}
}
matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
if _, ok := tokenModelLimit[matchName]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model}))
return
}
}
}

if shouldSelectChannel {
if modelRequest.Model == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired))
if shouldSelectChannel {
if modelRequest.Model == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired))
return
}
var selectGroup string
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
// check path is /pg/chat/completions
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
playgroundRequest := &dto.PlayGroundRequest{}
err = common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidPlayground, map[string]any{"Error": err.Error()}))
return
}
var selectGroup string
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
// check path is /pg/chat/completions
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
playgroundRequest := &dto.PlayGroundRequest{}
err = common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidPlayground, map[string]any{"Error": err.Error()}))
if playgroundRequest.Group != "" {
if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied))
return
}
if playgroundRequest.Group != "" {
if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied))
return
}
usingGroup = playgroundRequest.Group
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
}
usingGroup = playgroundRequest.Group
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
}
}

if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
}

if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
Retry: common.GetPointer(0),
})
if err != nil {
showGroup := usingGroup
if usingGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()})
// 如果错误,但是渠道不为空,说明是数据库一致性问题
//if channel != nil {
// common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
// message = "数据库一致性已被破坏,请联系管理员"
//}
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound)
return
}
if channel == nil {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
return
if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
Retry: common.GetPointer(0),
})
if err != nil {
showGroup := usingGroup
if usingGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()})
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound)
return
}
if channel == nil {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
return
}
}
}


+ 1
- 2
model/token.go Просмотреть файл

@@ -28,7 +28,6 @@ type Token struct {
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
BoundChannelId *int `json:"bound_channel_id" gorm:"index"` // 绑定的渠道ID,nil表示不绑定
DeletedAt gorm.DeletedAt `gorm:"index"`
}

@@ -284,7 +283,7 @@ func (token *Token) Update() (err error) {
}
}()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "bound_channel_id").Updates(token).Error
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
return err
}



+ 0
- 12
web/src/components/table/tokens/modals/EditTokenModal.jsx Просмотреть файл

@@ -76,7 +76,6 @@ const EditTokenModal = (props) => {
group: '',
cross_group_retry: false,
tokenCount: 1,
bound_channel_id: null,
});

const handleCancel = () => {
@@ -578,17 +577,6 @@ const EditTokenModal = (props) => {
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.Select
field='bound_channel_id'
label={t('绑定渠道')}
placeholder={t('可选,绑定后该令牌只能使用指定渠道')}
optionList={channels}
filter
showClear
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.TextArea
field='allow_ips'


Загрузка…
Отмена
Сохранить