删除了用户通过 sk-{key}:{channelId} 指定渠道、Playground 下拉框选择渠道、
以及管理员设置默认渠道 (is_default) 的所有后端代码。
涉及修改:
- middleware/auth.go: 简化 parseTokenKey,移除 specific_channel_id 设置
- middleware/distributor.go: 移除指定渠道分发逻辑、默认通道检查、ModelRequest.ChannelId
- constant/context_key.go: 移除 ContextKeyTokenSpecificChannelId
- controller/playground_channels.go: 整个文件删除
- controller/relay.go: 移除 shouldRetry/shouldRetryTaskRelay 中的指定渠道跳过
- controller/channel.go: 移除 GetUserChannelsForBinding
- controller/channel_pricing.go: 移除 SetDefaultChannel/ClearDefaultChannel
- model/ability.go: 移除 GetModelChannelsForGroup
- model/channel.go: 移除 GetAllChannelsForBinding
- model/channel_pricing.go: 移除默认通道缓存及所有相关函数
- model/pricing.go: 移除 DefaultChannelName 字段
- router/api-router.go: 移除 4 条路由
- i18n: 移除 channel.id_format_error 和 distributor.invalid_channel_id
master
| @@ -15,7 +15,6 @@ const ( | |||||
| ContextKeyTokenKey ContextKey = "token_key" | ContextKeyTokenKey ContextKey = "token_key" | ||||
| ContextKeyTokenId ContextKey = "token_id" | ContextKeyTokenId ContextKey = "token_id" | ||||
| ContextKeyTokenGroup ContextKey = "token_group" | ContextKeyTokenGroup ContextKey = "token_group" | ||||
| ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" | |||||
| ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" | ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" | ||||
| ContextKeyTokenModelLimit ContextKey = "token_model_limit" | ContextKeyTokenModelLimit ContextKey = "token_model_limit" | ||||
| ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" | ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" | ||||
| @@ -2106,30 +2106,3 @@ func OllamaVersion(c *gin.Context) { | |||||
| }) | }) | ||||
| } | } | ||||
| // GetUserChannelsForBinding 获取用户可用于绑定的渠道列表 | |||||
| // 只返回 id、name、type 和 remark,不包含敏感信息(如key) | |||||
| func GetUserChannelsForBinding(c *gin.Context) { | |||||
| channels, err := model.GetAllChannelsForBinding() | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| // 构建返回结果 | |||||
| result := make([]gin.H, 0, len(channels)) | |||||
| for _, ch := range channels { | |||||
| result = append(result, gin.H{ | |||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "public_name": ch.PublicName, | |||||
| "type": ch.Type, | |||||
| "remark": ch.Remark, | |||||
| }) | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "message": "", | |||||
| "data": result, | |||||
| }) | |||||
| } | |||||
| @@ -357,50 +357,3 @@ func GetChannelPricingWithTags(c *gin.Context) { | |||||
| }) | }) | ||||
| } | } | ||||
| // SetDefaultChannelRequest 设置默认通道请求 | |||||
| type SetDefaultChannelRequest struct { | |||||
| ModelName string `json:"model_name" binding:"required"` | |||||
| ChannelId int `json:"channel_id" binding:"required"` | |||||
| } | |||||
| // SetDefaultChannel 设置指定模型的默认通道 | |||||
| func SetDefaultChannel(c *gin.Context) { | |||||
| var req SetDefaultChannelRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| // 验证渠道定价记录存在 | |||||
| existing, err := model.GetChannelPricing(req.ModelName, req.ChannelId) | |||||
| if err != nil || existing == nil { | |||||
| common.ApiErrorMsg(c, "channel pricing not found for this model and channel") | |||||
| return | |||||
| } | |||||
| if err := model.SetDefaultChannel(req.ModelName, req.ChannelId); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] set default: model=%s channel=%d", req.ModelName, req.ChannelId)) | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| // ClearDefaultChannel 清除指定模型的默认通道 | |||||
| func ClearDefaultChannel(c *gin.Context) { | |||||
| modelName := c.Param("name") | |||||
| modelName = strings.TrimPrefix(modelName, "/") | |||||
| if modelName == "" { | |||||
| common.ApiErrorMsg(c, "model name is required") | |||||
| return | |||||
| } | |||||
| if err := model.ClearDefaultChannel(modelName); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] cleared default: model=%s", modelName)) | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| @@ -1,62 +0,0 @@ | |||||
| package controller | |||||
| import ( | |||||
| "net/http" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/gin-gonic/gin" | |||||
| ) | |||||
| func GetModelChannels(c *gin.Context) { | |||||
| modelName := c.Query("model") | |||||
| if modelName == "" { | |||||
| c.JSON(http.StatusBadRequest, gin.H{ | |||||
| "success": false, | |||||
| "message": "model parameter is required", | |||||
| }) | |||||
| return | |||||
| } | |||||
| userId := c.GetInt("id") | |||||
| if userId == 0 { | |||||
| c.JSON(http.StatusUnauthorized, gin.H{ | |||||
| "success": false, | |||||
| "message": "unauthorized", | |||||
| }) | |||||
| return | |||||
| } | |||||
| userCache, err := model.GetUserCache(userId) | |||||
| if err != nil || userCache == nil { | |||||
| c.JSON(http.StatusInternalServerError, gin.H{ | |||||
| "success": false, | |||||
| "message": "failed to get user info", | |||||
| }) | |||||
| return | |||||
| } | |||||
| userGroup := userCache.Group | |||||
| if userGroup == "" { | |||||
| c.JSON(http.StatusInternalServerError, gin.H{ | |||||
| "success": false, | |||||
| "message": "failed to get user group", | |||||
| }) | |||||
| return | |||||
| } | |||||
| channels, defaultChannelId, err := model.GetModelChannelsForGroup(modelName, userGroup) | |||||
| if err != nil { | |||||
| c.JSON(http.StatusInternalServerError, gin.H{ | |||||
| "success": false, | |||||
| "message": "failed to query channels", | |||||
| }) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "data": gin.H{ | |||||
| "channels": channels, | |||||
| "default_channel_id": defaultChannelId, | |||||
| }, | |||||
| }) | |||||
| } | |||||
| @@ -328,9 +328,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b | |||||
| if retryTimes <= 0 { | if retryTimes <= 0 { | ||||
| return false | return false | ||||
| } | } | ||||
| if _, ok := c.Get("specific_channel_id"); ok { | |||||
| return false | |||||
| } | |||||
| code := openaiErr.StatusCode | code := openaiErr.StatusCode | ||||
| if code >= 200 && code < 300 { | if code >= 200 && code < 300 { | ||||
| return false | return false | ||||
| @@ -614,9 +611,6 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, | |||||
| if retryTimes <= 0 { | if retryTimes <= 0 { | ||||
| return false | return false | ||||
| } | } | ||||
| if _, ok := c.Get("specific_channel_id"); ok { | |||||
| return false | |||||
| } | |||||
| if taskErr.StatusCode == http.StatusTooManyRequests { | if taskErr.StatusCode == http.StatusTooManyRequests { | ||||
| return true | return true | ||||
| } | } | ||||
| @@ -152,7 +152,6 @@ const ( | |||||
| // Channel related messages | // Channel related messages | ||||
| const ( | const ( | ||||
| MsgChannelNotExists = "channel.not_exists" | MsgChannelNotExists = "channel.not_exists" | ||||
| MsgChannelIdFormatError = "channel.id_format_error" | |||||
| MsgChannelNoAvailableKey = "channel.no_available_key" | MsgChannelNoAvailableKey = "channel.no_available_key" | ||||
| MsgChannelGetListFailed = "channel.get_list_failed" | MsgChannelGetListFailed = "channel.get_list_failed" | ||||
| MsgChannelGetTagsFailed = "channel.get_tags_failed" | MsgChannelGetTagsFailed = "channel.get_tags_failed" | ||||
| @@ -291,7 +290,6 @@ const ( | |||||
| // Distributor related messages | // Distributor related messages | ||||
| const ( | const ( | ||||
| MsgDistributorInvalidRequest = "distributor.invalid_request" | MsgDistributorInvalidRequest = "distributor.invalid_request" | ||||
| MsgDistributorInvalidChannelId = "distributor.invalid_channel_id" | |||||
| MsgDistributorChannelDisabled = "distributor.channel_disabled" | MsgDistributorChannelDisabled = "distributor.channel_disabled" | ||||
| MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access" | MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access" | ||||
| MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden" | MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden" | ||||
| @@ -132,7 +132,6 @@ topup.invalid_quota: "Invalid top-up quota" | |||||
| # Channel messages | # Channel messages | ||||
| channel.not_exists: "Channel does not exist" | channel.not_exists: "Channel does not exist" | ||||
| channel.id_format_error: "Channel ID format error" | |||||
| channel.no_available_key: "No available channel keys" | channel.no_available_key: "No available channel keys" | ||||
| channel.get_list_failed: "Failed to get channel list, please try again later" | channel.get_list_failed: "Failed to get channel list, please try again later" | ||||
| channel.get_tags_failed: "Failed to get tags, please try again later" | channel.get_tags_failed: "Failed to get tags, please try again later" | ||||
| @@ -243,7 +242,6 @@ common.invalid_input: "Invalid input" | |||||
| # Distributor messages | # Distributor messages | ||||
| distributor.invalid_request: "Invalid request: {{.Error}}" | distributor.invalid_request: "Invalid request: {{.Error}}" | ||||
| distributor.invalid_channel_id: "Invalid channel ID" | |||||
| distributor.channel_disabled: "This channel has been disabled" | distributor.channel_disabled: "This channel has been disabled" | ||||
| distributor.token_no_model_access: "This token has no access to any models" | distributor.token_no_model_access: "This token has no access to any models" | ||||
| distributor.token_model_forbidden: "This token has no access to model {{.Model}}" | distributor.token_model_forbidden: "This token has no access to model {{.Model}}" | ||||
| @@ -133,7 +133,6 @@ topup.invalid_quota: "无效的充值额度" | |||||
| # Channel messages | # Channel messages | ||||
| channel.not_exists: "渠道不存在" | channel.not_exists: "渠道不存在" | ||||
| channel.id_format_error: "渠道ID格式错误" | |||||
| channel.no_available_key: "没有可用的渠道密钥" | channel.no_available_key: "没有可用的渠道密钥" | ||||
| channel.get_list_failed: "获取渠道列表失败,请稍后重试" | channel.get_list_failed: "获取渠道列表失败,请稍后重试" | ||||
| channel.get_tags_failed: "获取标签失败,请稍后重试" | channel.get_tags_failed: "获取标签失败,请稍后重试" | ||||
| @@ -244,7 +243,6 @@ common.invalid_input: "输入不合法" | |||||
| # Distributor messages | # Distributor messages | ||||
| distributor.invalid_request: "无效的请求,{{.Error}}" | distributor.invalid_request: "无效的请求,{{.Error}}" | ||||
| distributor.invalid_channel_id: "无效的渠道 Id" | |||||
| distributor.channel_disabled: "该渠道已被禁用" | distributor.channel_disabled: "该渠道已被禁用" | ||||
| distributor.token_no_model_access: "该令牌无权访问任何模型" | distributor.token_no_model_access: "该令牌无权访问任何模型" | ||||
| distributor.token_model_forbidden: "该令牌无权访问模型 {{.Model}}" | distributor.token_model_forbidden: "该令牌无权访问模型 {{.Model}}" | ||||
| @@ -133,7 +133,6 @@ topup.invalid_quota: "無效的充值額度" | |||||
| # Channel messages | # Channel messages | ||||
| channel.not_exists: "管道不存在" | channel.not_exists: "管道不存在" | ||||
| channel.id_format_error: "管道ID格式錯誤" | |||||
| channel.no_available_key: "沒有可用的管道密鑰" | channel.no_available_key: "沒有可用的管道密鑰" | ||||
| channel.get_list_failed: "獲取管道列表失敗,請稍後重試" | channel.get_list_failed: "獲取管道列表失敗,請稍後重試" | ||||
| channel.get_tags_failed: "獲取標籤失敗,請稍後重試" | channel.get_tags_failed: "獲取標籤失敗,請稍後重試" | ||||
| @@ -244,7 +243,6 @@ common.invalid_input: "輸入不合法" | |||||
| # Distributor messages | # Distributor messages | ||||
| distributor.invalid_request: "無效的請求,{{.Error}}" | distributor.invalid_request: "無效的請求,{{.Error}}" | ||||
| distributor.invalid_channel_id: "無效的管道 Id" | |||||
| distributor.channel_disabled: "該管道已被禁用" | distributor.channel_disabled: "該管道已被禁用" | ||||
| distributor.token_no_model_access: "該令牌無權存取任何模型" | distributor.token_no_model_access: "該令牌無權存取任何模型" | ||||
| distributor.token_model_forbidden: "該令牌無權存取模型 {{.Model}}" | distributor.token_model_forbidden: "該令牌無權存取模型 {{.Model}}" | ||||
| @@ -30,29 +30,12 @@ func validUserInfo(username string, role int) bool { | |||||
| return true | return true | ||||
| } | } | ||||
| // parseTokenKey 解析令牌字符串,提取 key 和可选的 channelId | |||||
| // 支持格式:sk-{key} 或 sk-{key}:{channelId} 或 sk-{key}-{channelId} | |||||
| // 返回:key, parts(parts[0]=key, parts[1]=channelId 如果指定) | |||||
| // parseTokenKey 解析令牌字符串,提取 key | |||||
| // 支持格式:sk-{key} | |||||
| func parseTokenKey(key string) (string, []string) { | func parseTokenKey(key string) (string, []string) { | ||||
| key = strings.TrimPrefix(key, "sk-") | 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, "-") | parts := strings.Split(key, "-") | ||||
| key = parts[0] | key = parts[0] | ||||
| // 如果冒号方式指定了渠道,验证并追加到 parts | |||||
| if colonChannelId != "" { | |||||
| // 验证 channelId 为有效数字,防止 sk-abc:def:ghi 等格式 | |||||
| if _, err := strconv.Atoi(colonChannelId); err == nil { | |||||
| parts = append(parts, colonChannelId) | |||||
| } | |||||
| } | |||||
| return key, parts | return key, parts | ||||
| } | } | ||||
| @@ -409,9 +392,5 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e | |||||
| } | } | ||||
| common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group) | common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group) | ||||
| common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry) | common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry) | ||||
| // 所有用户都可以通过 sk-{key}:{channelId} 或 sk-{key}-{channelId} 指定渠道 | |||||
| if len(parts) > 1 { | |||||
| c.Set("specific_channel_id", parts[1]) | |||||
| } | |||||
| return nil | return nil | ||||
| } | } | ||||
| @@ -5,7 +5,6 @@ import ( | |||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "slices" | "slices" | ||||
| "strconv" | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -23,9 +22,8 @@ import ( | |||||
| ) | ) | ||||
| type ModelRequest struct { | type ModelRequest struct { | ||||
| Model string `json:"model"` | |||||
| Group string `json:"group,omitempty"` | |||||
| ChannelId int `json:"channel_id,omitempty"` | |||||
| Model string `json:"model"` | |||||
| Group string `json:"group,omitempty"` | |||||
| } | } | ||||
| func Distribute() func(c *gin.Context) { | func Distribute() func(c *gin.Context) { | ||||
| @@ -36,31 +34,7 @@ func Distribute() func(c *gin.Context) { | |||||
| abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) | abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) | ||||
| return | return | ||||
| } | } | ||||
| channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) | |||||
| if ok { | |||||
| id, err := strconv.Atoi(channelId.(string)) | |||||
| if err != nil { | |||||
| abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) | |||||
| return | |||||
| } | |||||
| channel, err = model.GetChannelById(id, true) | |||||
| if err != nil { | |||||
| abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) | |||||
| return | |||||
| } | |||||
| if channel.Status != common.ChannelStatusEnabled { | |||||
| 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 { | |||||
| // Normal channel selection logic | // Normal channel selection logic | ||||
| // check token model mapping | // check token model mapping | ||||
| modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) | modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) | ||||
| @@ -108,39 +82,6 @@ func Distribute() func(c *gin.Context) { | |||||
| } | } | ||||
| } | } | ||||
| // 默认通道检查(管理员配置的模型默认通道) | |||||
| if channel == nil { | |||||
| if defaultChannelId, ok := model.GetDefaultChannelId(modelRequest.Model); ok { | |||||
| defaultCh, err := model.CacheGetChannel(defaultChannelId) | |||||
| if err != nil || defaultCh == nil { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not found in cache, fallback", modelRequest.Model, defaultChannelId)) | |||||
| } else if defaultCh.Status != common.ChannelStatusEnabled { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d disabled(status=%d), fallback", modelRequest.Model, defaultChannelId, defaultCh.Status)) | |||||
| } else if usingGroup == "auto" { | |||||
| userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) | |||||
| autoGroups := service.GetUserAutoGroup(userGroup) | |||||
| for _, g := range autoGroups { | |||||
| if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, defaultCh.Id) { | |||||
| channel = defaultCh | |||||
| selectGroup = g | |||||
| common.SetContextKey(c, constant.ContextKeyAutoGroup, g) | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (auto group=%s)", modelRequest.Model, defaultChannelId, g)) | |||||
| break | |||||
| } | |||||
| } | |||||
| if channel == nil { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for any auto group, fallback", modelRequest.Model, defaultChannelId)) | |||||
| } | |||||
| } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, defaultCh.Id) { | |||||
| channel = defaultCh | |||||
| selectGroup = usingGroup | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (group=%s)", modelRequest.Model, defaultChannelId, usingGroup)) | |||||
| } else { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for group=%s, fallback", modelRequest.Model, defaultChannelId, usingGroup)) | |||||
| } | |||||
| } | |||||
| } | |||||
| // 通道亲和性检查(仅在未选中默认通道时生效) | // 通道亲和性检查(仅在未选中默认通道时生效) | ||||
| if channel == nil { | if channel == nil { | ||||
| if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { | if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { | ||||
| @@ -189,7 +130,6 @@ func Distribute() func(c *gin.Context) { | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| } | |||||
| common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) | common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) | ||||
| SetupContextForSelectedChannel(c, channel, modelRequest.Model) | SetupContextForSelectedChannel(c, channel, modelRequest.Model) | ||||
| c.Next() | c.Next() | ||||
| @@ -374,12 +314,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { | |||||
| modelRequest.Group = g | modelRequest.Group = g | ||||
| } | } | ||||
| common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) | common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) | ||||
| // channel_id: body 优先,fallback 到 header | |||||
| if req.ChannelId > 0 { | |||||
| common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, strconv.Itoa(req.ChannelId)) | |||||
| } else if ch := c.GetHeader("X-Channel-Id"); ch != "" { | |||||
| common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, ch) | |||||
| } | |||||
| } | } | ||||
| if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" { | if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" { | ||||
| @@ -66,58 +66,6 @@ func GetAbilitiesByChannelId(channelId int) ([]*Ability, error) { | |||||
| return abilities, err | return abilities, err | ||||
| } | } | ||||
| // GetModelChannelsForGroup 返回指定模型在指定分组下的可用渠道列表及默认渠道ID | |||||
| func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, int, error) { | |||||
| var channelIds []int | |||||
| err := DB.Model(&Ability{}). | |||||
| Where("model = ?", modelName). | |||||
| Where("enabled = ?", true). | |||||
| Where(commonGroupCol+" = ?", group). | |||||
| Distinct("channel_id"). | |||||
| Pluck("channel_id", &channelIds).Error | |||||
| if err != nil { | |||||
| return nil, 0, err | |||||
| } | |||||
| if len(channelIds) == 0 { | |||||
| return []map[string]any{}, 0, nil | |||||
| } | |||||
| type channelInfo struct { | |||||
| Id int `json:"id"` | |||||
| Name string `json:"name"` | |||||
| PublicName string `json:"public_name"` | |||||
| } | |||||
| var channels []channelInfo | |||||
| err = DB.Table("channels"). | |||||
| Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled). | |||||
| Select("id, name, public_name"). | |||||
| Find(&channels).Error | |||||
| if err != nil { | |||||
| return nil, 0, err | |||||
| } | |||||
| defaultChannelId := 0 | |||||
| if defaultChId, ok := GetDefaultChannelId(modelName); ok { | |||||
| for _, id := range channelIds { | |||||
| if id == defaultChId { | |||||
| defaultChannelId = defaultChId | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| result := make([]map[string]any, 0, len(channels)) | |||||
| for _, ch := range channels { | |||||
| result = append(result, map[string]any{ | |||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "public_name": ch.PublicName, | |||||
| }) | |||||
| } | |||||
| return result, defaultChannelId, nil | |||||
| } | |||||
| func getPriority(group string, model string, retry int) (int, error) { | func getPriority(group string, model string, retry int) (int, error) { | ||||
| @@ -283,16 +283,6 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan | |||||
| return channels, err | return channels, err | ||||
| } | } | ||||
| // GetAllChannelsForBinding 获取所有启用的渠道(用于用户绑定渠道) | |||||
| // 只返回 id, name, type, remark,不包含敏感信息 | |||||
| func GetAllChannelsForBinding() ([]*Channel, error) { | |||||
| var channels []*Channel | |||||
| err := DB.Select("id, name, public_name, type, remark"). | |||||
| Where("status = ?", common.ChannelStatusEnabled). | |||||
| Order("priority desc"). | |||||
| Find(&channels).Error | |||||
| return channels, err | |||||
| } | |||||
| func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { | func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { | ||||
| var channels []*Channel | var channels []*Channel | ||||
| @@ -16,10 +16,6 @@ import ( | |||||
| var ( | var ( | ||||
| channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" | channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" | ||||
| channelPricingCacheLock sync.RWMutex | channelPricingCacheLock sync.RWMutex | ||||
| // 默认通道缓存:modelName → channelId | |||||
| defaultChannelCache = make(map[string]int) | |||||
| defaultChannelCacheLock sync.RWMutex | |||||
| ) | ) | ||||
| // QuotaType 计费类型 | // QuotaType 计费类型 | ||||
| @@ -85,9 +81,7 @@ func (cp *ChannelPricing) Insert() error { | |||||
| err := DB.Create(cp).Error | err := DB.Create(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | ||||
| if cp.IsDefault { | |||||
| setDefaultChannelCache(cp.ModelName, cp.ChannelId) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -101,11 +95,7 @@ func (cp *ChannelPricing) Update() error { | |||||
| Updates(cp).Error | Updates(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | ||||
| if cp.IsDefault { | |||||
| setDefaultChannelCache(cp.ModelName, cp.ChannelId) | |||||
| } else { | |||||
| clearDefaultChannelCacheIfMatch(cp.ModelName, cp.Id) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -118,9 +108,7 @@ func (cp *ChannelPricing) Delete() error { | |||||
| err := DB.Delete(cp).Error | err := DB.Delete(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) | removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) | ||||
| if existing.IsDefault { | |||||
| clearDefaultChannelCache(existing.ModelName) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -243,7 +231,6 @@ func LoadChannelPricingCache() { | |||||
| } | } | ||||
| channelPricingCacheLock.Unlock() | channelPricingCacheLock.Unlock() | ||||
| rebuildDefaultChannelCache(pricings) | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) | common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) | ||||
| } | } | ||||
| @@ -344,107 +331,3 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| return results, nil | return results, nil | ||||
| } | } | ||||
| // === 默认通道缓存 === | |||||
| // syncIsDefaultToCache 将默认标记的变更同步到 channelPricingCache | |||||
| func syncIsDefaultToCache(modelName string, channelId int, isDefault bool) { | |||||
| channelPricingCacheLock.Lock() | |||||
| for key, cp := range channelPricingCache { | |||||
| if cp.ModelName == modelName { | |||||
| if isDefault { | |||||
| cp.IsDefault = cp.ChannelId == channelId | |||||
| } else { | |||||
| cp.IsDefault = false | |||||
| } | |||||
| } | |||||
| channelPricingCache[key] = cp | |||||
| } | |||||
| channelPricingCacheLock.Unlock() | |||||
| } | |||||
| // setDefaultChannelCache 设置默认通道缓存 | |||||
| func setDefaultChannelCache(modelName string, channelId int) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| defaultChannelCache[modelName] = channelId | |||||
| defaultChannelCacheLock.Unlock() | |||||
| } | |||||
| // clearDefaultChannelCache 清除指定模型的默认通道缓存 | |||||
| func clearDefaultChannelCache(modelName string) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| delete(defaultChannelCache, modelName) | |||||
| defaultChannelCacheLock.Unlock() | |||||
| } | |||||
| // clearDefaultChannelCacheIfMatch 如果默认通道的定价记录 ID 匹配则清除 | |||||
| func clearDefaultChannelCacheIfMatch(modelName string, pricingId int) { | |||||
| defaultChannelCacheLock.RLock() | |||||
| cachedId, ok := defaultChannelCache[modelName] | |||||
| defaultChannelCacheLock.RUnlock() | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| // 需要通过缓存找到对应的 pricing 来比对 | |||||
| key := getChannelPricingCacheKey(modelName, cachedId) | |||||
| channelPricingCacheLock.RLock() | |||||
| cp, exists := channelPricingCache[key] | |||||
| channelPricingCacheLock.RUnlock() | |||||
| if exists && cp.Id == pricingId { | |||||
| clearDefaultChannelCache(modelName) | |||||
| } | |||||
| } | |||||
| // rebuildDefaultChannelCache 从全量数据构建默认通道缓存(启动时调用) | |||||
| func rebuildDefaultChannelCache(pricings []*ChannelPricing) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| defaultChannelCache = make(map[string]int) | |||||
| for _, cp := range pricings { | |||||
| if cp.IsDefault { | |||||
| defaultChannelCache[cp.ModelName] = cp.ChannelId | |||||
| } | |||||
| } | |||||
| defaultChannelCacheLock.Unlock() | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] default channel cache loaded %d records", len(defaultChannelCache))) | |||||
| } | |||||
| // GetDefaultChannelId 获取指定模型的默认通道 ID(纯内存读) | |||||
| func GetDefaultChannelId(modelName string) (int, bool) { | |||||
| defaultChannelCacheLock.RLock() | |||||
| id, ok := defaultChannelCache[modelName] | |||||
| defaultChannelCacheLock.RUnlock() | |||||
| return id, ok | |||||
| } | |||||
| // SetDefaultChannel 设置指定模型的默认通道(事务保证互斥) | |||||
| func SetDefaultChannel(modelName string, channelId int) error { | |||||
| return DB.Transaction(func(tx *gorm.DB) error { | |||||
| // 清除该模型所有现有的默认标记 | |||||
| if err := tx.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND is_default = ?", modelName, true). | |||||
| Update("is_default", false).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| // 设置新的默认 | |||||
| if err := tx.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND channel_id = ?", modelName, channelId). | |||||
| Update("is_default", true).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| // 更新缓存 | |||||
| setDefaultChannelCache(modelName, channelId) | |||||
| syncIsDefaultToCache(modelName, channelId, true) | |||||
| return nil | |||||
| }) | |||||
| } | |||||
| // ClearDefaultChannel 清除指定模型的默认通道标记 | |||||
| func ClearDefaultChannel(modelName string) error { | |||||
| err := DB.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND is_default = ?", modelName, true). | |||||
| Update("is_default", false).Error | |||||
| if err == nil { | |||||
| clearDefaultChannelCache(modelName) | |||||
| syncIsDefaultToCache(modelName, 0, false) | |||||
| } | |||||
| return err | |||||
| } | |||||
| @@ -119,124 +119,3 @@ func TestGetEffectivePricing_NotFound(t *testing.T) { | |||||
| assert.False(t, ok) | assert.False(t, ok) | ||||
| assert.Nil(t, found) | assert.Nil(t, found) | ||||
| } | } | ||||
| // === 默认通道测试 === | |||||
| func TestDefaultChannel_SetAndGet(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| // 创建两条定价记录 | |||||
| cp1 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 100, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} | |||||
| cp2 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 200, QuotaType: QuotaTypeByTokens, ModelRatio: 2.0} | |||||
| require.NoError(t, cp1.Insert()) | |||||
| require.NoError(t, cp2.Insert()) | |||||
| t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) | |||||
| // 初始没有默认 | |||||
| _, ok := GetDefaultChannelId("default-test-model") | |||||
| assert.False(t, ok) | |||||
| // 设置通道 100 为默认 | |||||
| require.NoError(t, SetDefaultChannel("default-test-model", 100)) | |||||
| id, ok := GetDefaultChannelId("default-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 100, id) | |||||
| // 切换默认到通道 200 | |||||
| require.NoError(t, SetDefaultChannel("default-test-model", 200)) | |||||
| id, ok = GetDefaultChannelId("default-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 200, id) | |||||
| // 验证旧的默认标记被清除 | |||||
| found, _ := GetEffectivePricing("default-test-model", 100) | |||||
| assert.False(t, found.IsDefault) | |||||
| found, _ = GetEffectivePricing("default-test-model", 200) | |||||
| assert.True(t, found.IsDefault) | |||||
| } | |||||
| func TestDefaultChannel_Clear(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "clear-test-model", ChannelId: 300, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} | |||||
| require.NoError(t, cp.Insert()) | |||||
| t.Cleanup(func() { cp.Delete() }) | |||||
| require.NoError(t, SetDefaultChannel("clear-test-model", 300)) | |||||
| _, ok := GetDefaultChannelId("clear-test-model") | |||||
| assert.True(t, ok) | |||||
| require.NoError(t, ClearDefaultChannel("clear-test-model")) | |||||
| _, ok = GetDefaultChannelId("clear-test-model") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_DeleteClearsCache(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "delete-test-model", ChannelId: 400, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0, IsDefault: true} | |||||
| require.NoError(t, cp.Insert()) | |||||
| id, ok := GetDefaultChannelId("delete-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 400, id) | |||||
| // 删除记录后缓存应清除 | |||||
| require.NoError(t, cp.Delete()) | |||||
| _, ok = GetDefaultChannelId("delete-test-model") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_LoadCache(t *testing.T) { | |||||
| db := setupChannelPricingDB(t) | |||||
| // 直接插入数据(绕过缓存) | |||||
| db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 500, ModelRatio: 1.0, IsDefault: true}) | |||||
| db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 501, ModelRatio: 2.0, IsDefault: false}) | |||||
| db.Create(&ChannelPricing{ModelName: "other-model", ChannelId: 502, ModelRatio: 1.0, IsDefault: true}) | |||||
| // 全量加载缓存 | |||||
| LoadChannelPricingCache() | |||||
| id, ok := GetDefaultChannelId("load-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 500, id) | |||||
| id, ok = GetDefaultChannelId("other-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 502, id) | |||||
| // 无默认的模型 | |||||
| _, ok = GetDefaultChannelId("nonexistent") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_InsertWithDefault(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "insert-default-model", ChannelId: 600, ModelRatio: 1.0, IsDefault: true} | |||||
| require.NoError(t, cp.Insert()) | |||||
| t.Cleanup(func() { cp.Delete() }) | |||||
| id, ok := GetDefaultChannelId("insert-default-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 600, id) | |||||
| } | |||||
| func TestDefaultChannel_UpdateWithDefault(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp1 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 700, ModelRatio: 1.0, IsDefault: true} | |||||
| cp2 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 701, ModelRatio: 2.0} | |||||
| require.NoError(t, cp1.Insert()) | |||||
| require.NoError(t, cp2.Insert()) | |||||
| t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) | |||||
| // cp1 是默认,通过 Update 把 cp2 设为默认 | |||||
| cp2.IsDefault = true | |||||
| require.NoError(t, cp2.Update()) | |||||
| id, ok := GetDefaultChannelId("update-default-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 701, id) | |||||
| } | |||||
| @@ -32,7 +32,6 @@ type Pricing struct { | |||||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | ||||
| PricingVersion string `json:"pricing_version,omitempty"` | PricingVersion string `json:"pricing_version,omitempty"` | ||||
| Type int `json:"type"` | Type int `json:"type"` | ||||
| DefaultChannelName string `json:"default_channel_name,omitempty"` | |||||
| } | } | ||||
| type PricingVendor struct { | type PricingVendor struct { | ||||
| @@ -350,12 +349,6 @@ func updatePricing() { | |||||
| // 使用渠道定价表中的实际数据,选取最便宜的渠道 | // 使用渠道定价表中的实际数据,选取最便宜的渠道 | ||||
| applyBestChannelPricing(&pricing, cpMap[model], model) | applyBestChannelPricing(&pricing, cpMap[model], model) | ||||
| // 填充默认通道名称 | |||||
| if chId, ok := GetDefaultChannelId(model); ok { | |||||
| if name, found := channelNameMap[chId]; found { | |||||
| pricing.DefaultChannelName = name | |||||
| } | |||||
| } | |||||
| pricingMap = append(pricingMap, pricing) | pricingMap = append(pricingMap, pricing) | ||||
| } | } | ||||
| @@ -77,9 +77,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| selfRoute.GET("/self/groups", controller.GetUserGroups) | selfRoute.GET("/self/groups", controller.GetUserGroups) | ||||
| selfRoute.GET("/self", controller.GetSelf) | selfRoute.GET("/self", controller.GetSelf) | ||||
| selfRoute.GET("/models", controller.GetUserModels) | selfRoute.GET("/models", controller.GetUserModels) | ||||
| selfRoute.GET("/model_channels", controller.GetModelChannels) | |||||
| selfRoute.GET("/channels", controller.GetUserChannelsForBinding) | |||||
| selfRoute.PUT("/self", controller.UpdateSelf) | |||||
| selfRoute.PUT("/self", controller.UpdateSelf) | |||||
| selfRoute.DELETE("/self", controller.DeleteSelf) | selfRoute.DELETE("/self", controller.DeleteSelf) | ||||
| selfRoute.GET("/token", controller.GenerateAccessToken) | selfRoute.GET("/token", controller.GenerateAccessToken) | ||||
| selfRoute.GET("/passkey", controller.PasskeyStatus) | selfRoute.GET("/passkey", controller.PasskeyStatus) | ||||
| @@ -204,9 +202,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) | channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) | ||||
| channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) | channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) | ||||
| channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) | channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) | ||||
| channelPricingRoute.POST("/set_default", controller.SetDefaultChannel) | |||||
| channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel) | |||||
| } | |||||
| } | |||||
| // 用户倍率路由(管理员权限) | // 用户倍率路由(管理员权限) | ||||
| userChannelRatioRoute := apiRouter.Group("/user_channel_ratio") | userChannelRatioRoute := apiRouter.Group("/user_channel_ratio") | ||||