package controller import ( "context" "encoding/json" "errors" "fmt" "net/http" "slices" "strconv" "strings" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/jsplugin" relaychannel "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/ollama" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service/authz" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type OpenAIModel struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` Metadata map[string]any `json:"metadata,omitempty"` Permission []struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` AllowCreateEngine bool `json:"allow_create_engine"` AllowSampling bool `json:"allow_sampling"` AllowLogprobs bool `json:"allow_logprobs"` AllowSearchIndices bool `json:"allow_search_indices"` AllowView bool `json:"allow_view"` AllowFineTuning bool `json:"allow_fine_tuning"` Organization string `json:"organization"` Group string `json:"group"` IsBlocking bool `json:"is_blocking"` } `json:"permission"` Root string `json:"root"` Parent string `json:"parent"` } type OpenAIModelsResponse struct { Data []OpenAIModel `json:"data"` Success bool `json:"success"` } func parseStatusFilter(statusParam string) int { switch strings.ToLower(statusParam) { case "enabled", "1": return common.ChannelStatusEnabled case "disabled", "0": return 0 default: return -1 } } func clearChannelInfo(channel *model.Channel) { if channel.ChannelInfo.IsMultiKey { channel.ChannelInfo.MultiKeyDisabledReason = nil channel.ChannelInfo.MultiKeyDisabledTime = nil } } func channelIDsFromChannels(channels []*model.Channel) []int { ids := make([]int, 0, len(channels)) for _, channel := range channels { if channel != nil && channel.Id > 0 { ids = append(ids, channel.Id) } } return ids } func closeActiveChannelWebSockets(channelIDs []int) { service.CloseActiveWebSocketsForChannels(channelIDs, service.ChannelDisabledCloseReason) } func hasEnabledMultiKey(channel *model.Channel) bool { if channel == nil || !channel.ChannelInfo.IsMultiKey { return true } keys := channel.GetKeys() if len(keys) == 0 { return false } for i := range keys { if channel.ChannelInfo.MultiKeyStatusList == nil { return true } if status, ok := channel.ChannelInfo.MultiKeyStatusList[i]; !ok || status == common.ChannelStatusEnabled { return true } } return false } func disableMultiKeyChannelIfUnavailable(channel *model.Channel) bool { if channel == nil || !channel.ChannelInfo.IsMultiKey || hasEnabledMultiKey(channel) { return false } if channel.Status != common.ChannelStatusEnabled { return true } channel.Status = common.ChannelStatusManuallyDisabled info := channel.GetOtherInfo() info["status_reason"] = model.ChannelStatusReasonAllKeysDisabled info["status_time"] = common.GetTimestamp() channel.SetOtherInfo(info) return true } func restoreMultiKeyChannelIfAvailable(channel *model.Channel) { if channel.Status != common.ChannelStatusManuallyDisabled || !hasEnabledMultiKey(channel) { return } info := channel.GetOtherInfo() if info["status_reason"] != model.ChannelStatusReasonAllKeysDisabled { return } channel.Status = common.ChannelStatusEnabled info["status_reason"] = "" info["status_time"] = common.GetTimestamp() channel.SetOtherInfo(info) } func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB { if statusFilter == common.ChannelStatusEnabled { return query.Where("status = ?", common.ChannelStatusEnabled) } if statusFilter == 0 { return query.Where("status != ?", common.ChannelStatusEnabled) } return query } func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB { query := model.DB.Model(&model.Channel{}) query = model.ApplyChannelGroupFilter(query, group) query = applyChannelStatusFilter(query, statusFilter) if typeFilter >= 0 { query = query.Where("type = ?", typeFilter) } return query } func GetChannelOps(c *gin.Context) { snapshot := model.CurrentRequestPolicy() automaticDisable, source := snapshot.AutoDisable, "global" if value, present := c.GetQuery("auto_ban"); present { autoBan, err := strconv.ParseBool(value) if err != nil { common.ApiError(c, err) return } automaticDisable = snapshot.AutoDisable && autoBan if snapshot.AutoDisable { source = "global_and_channel" if !autoBan { source = "channel" } } } common.ApiSuccess(c, gin.H{ "retry_times": snapshot.RetryTimes, "request_policy": gin.H{"automatic_disable": automaticDisable, "source": source}, }) } func GetChannelDefaultBaseURLs(c *gin.Context) { baseURLs := make(map[int]string) for channelType, baseURL := range constant.ChannelBaseURLs { if baseURL != "" { baseURLs[channelType] = baseURL } } common.ApiSuccess(c, baseURLs) } func GetAllChannels(c *gin.Context) { pageInfo := common.GetPageQuery(c) channelData := make([]*model.Channel, 0) idSort, _ := strconv.ParseBool(c.Query("id_sort")) sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort) enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) groupFilter := model.NormalizeChannelGroupFilter(c.Query("group")) statusParam := c.Query("status") // statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual) statusFilter := parseStatusFilter(statusParam) // type filter typeStr := c.Query("type") typeFilter := -1 if typeStr != "" { if t, err := strconv.Atoi(typeStr); err == nil { typeFilter = t } } var total int64 if enableTagMode { tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.SysError(common.LogText("failed to get paginated tags: %s", err.Error())) common.ApiErrorT(c, "Failed to fetch tags. Please try again later") return } total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter)) if err != nil { common.SysError(common.LogText("failed to count tags: %s", err.Error())) common.ApiErrorT(c, "Failed to count tags. Please try again later") return } for _, tag := range tags { if tag == nil || *tag == "" { continue } var tagChannels []*model.Channel err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)). Omit("key"). Find(&tagChannels).Error if err != nil { common.SysError(common.LogText("failed to get channels by tag: %s", err.Error())) common.ApiErrorT(c, "Failed to fetch channels with this tag. Please try again later") return } channelData = append(channelData, tagChannels...) } } else { if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil { common.SysError(common.LogText("failed to count channels: %s", err.Error())) common.ApiErrorT(c, "Failed to count channels. Please try again later") return } err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)). Limit(pageInfo.GetPageSize()). Offset(pageInfo.GetStartIdx()). Omit("key"). Find(&channelData).Error if err != nil { common.SysError(common.LogText("failed to get channels: %s", err.Error())) common.ApiErrorT(c, "Failed to fetch channels. Please try again later") return } } for _, datum := range channelData { clearChannelInfo(datum) } countQuery := buildChannelListQuery(groupFilter, statusFilter, -1) var results []struct { Type int64 Count int64 } if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil { common.SysError(common.LogText("failed to count channel types: %s", err.Error())) common.ApiErrorT(c, "Failed to count channel types. Please try again later") return } typeCounts := make(map[int64]int64) for _, r := range results { typeCounts[r.Type] = r.Count } common.ApiSuccess(c, gin.H{ "items": channelData, "total": total, "page": pageInfo.GetPage(), "page_size": pageInfo.GetPageSize(), "type_counts": typeCounts, }) return } func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, error) { var headers http.Header switch channel.Type { case constant.ChannelTypeAnthropic: headers = GetClaudeAuthHeader(key) default: headers = GetAuthHeader(key) } if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil { return nil, err } return headers, nil } func applyFetchModelsHeaderOverrides(channel *model.Channel, key string, headers http.Header) error { info := &relaycommon.RelayInfo{ IsChannelTest: true, ChannelMeta: &relaycommon.ChannelMeta{ ApiKey: key, HeadersOverride: channel.GetHeaderOverride(), }, } overrides, err := relaychannel.ResolveHeaderOverride(info, nil) if err != nil { return err } for name, value := range overrides { headers.Set(name, value) } return nil } func FetchUpstreamModels(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { common.ApiError(c, err) return } channel, err := model.GetChannelById(id, true) if err != nil { common.ApiError(c, err) return } ids, err := fetchChannelUpstreamModelIDs(channel) if err != nil { common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": ids, }) } func FixChannelsAbilities(c *gin.Context) { success, fails, err := model.FixAbility() if err != nil { common.ApiError(c, err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": gin.H{ "success": success, "fails": fails, }, }) } func SearchChannels(c *gin.Context) { keyword := c.Query("keyword") group := c.Query("group") modelKeyword := c.Query("model") statusParam := c.Query("status") statusFilter := parseStatusFilter(statusParam) idSort, _ := strconv.ParseBool(c.Query("id_sort")) sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort) enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) channelData := make([]*model.Channel, 0) if enableTagMode { tags, err := model.SearchTags(keyword, group, modelKeyword, idSort) if err != nil { common.ApiError(c, err) return } for _, tag := range tags { if tag != nil && *tag != "" { var tagChannels []*model.Channel err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)). Omit("key"). Find(&tagChannels).Error if err != nil { common.ApiError(c, err) return } channelData = append(channelData, tagChannels...) } } } else { channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions) if err != nil { common.ApiError(c, err) return } channelData = channels } if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 { filtered := make([]*model.Channel, 0, len(channelData)) for _, ch := range channelData { if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { continue } if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { continue } filtered = append(filtered, ch) } channelData = filtered } // calculate type counts for search results typeCounts := make(map[int64]int64) for _, channel := range channelData { typeCounts[int64(channel.Type)]++ } typeParam := c.Query("type") typeFilter := -1 if typeParam != "" { if tp, err := strconv.Atoi(typeParam); err == nil { typeFilter = tp } } if typeFilter >= 0 { filtered := make([]*model.Channel, 0, len(channelData)) for _, ch := range channelData { if ch.Type == typeFilter { filtered = append(filtered, ch) } } channelData = filtered } page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize <= 0 { pageSize = 20 } total := len(channelData) startIdx := min((page-1)*pageSize, total) endIdx := min(startIdx+pageSize, total) pagedData := channelData[startIdx:endIdx] for _, datum := range pagedData { clearChannelInfo(datum) } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": gin.H{ "items": pagedData, "total": total, "type_counts": typeCounts, }, }) return } func GetChannel(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { common.ApiError(c, err) return } channel, err := model.GetChannelById(id, false) if err != nil { common.ApiError(c, err) return } if channel != nil { clearChannelInfo(channel) } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": channel, }) return } // GetChannelKey 获取渠道密钥(需要通过安全验证中间件) // 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 func GetChannelKey(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) if err != nil || channelId <= 0 { common.ApiErrorT(c, "Invalid channel ID") return } // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) if errors.Is(err, gorm.ErrRecordNotFound) { common.ApiErrorT(c, "Channel does not exist") return } if err != nil { writeSecurityOperationError(c, err) return } // 记录操作审计日志(高危:查看渠道密钥) recordManageAudit(c, "channel.key_view", map[string]any{ "id": channelId, "name": channel.Name, }) // 返回渠道密钥 common.ApiSuccessT(c, "Fetched successfully", map[string]any{ "key": channel.Key, }) } // maxTaskExtendPluginKeys bounds the plugins one New API channel can be // extended with; it comfortably covers every built-in and installed plugin. const maxTaskExtendPluginKeys = 32 // validateChannel 通用的渠道校验函数 func validateChannel(channel *model.Channel, isAdd bool) error { if channel == nil { return fmt.Errorf("channel cannot be empty") } // 校验 channel settings if err := channel.ValidateSettings(); err != nil { return common.NewMessage("Invalid channel settings format: {{error}}", map[string]any{"error": err.Error()}) } if channel.Type == constant.ChannelTypeTaskPlugin { pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey) if pluginKey == "" { return fmt.Errorf("task plugin key is required") } if len(pluginKey) > 30 { return fmt.Errorf("task plugin key must not exceed 30 characters") } plugin, ok := jsplugin.DefaultRegistry.Get(pluginKey) if !ok { return fmt.Errorf("task plugin %q is not registered", pluginKey) } if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" { // The plugin default is persisted onto the channel instead of being // resolved per request, so the destination host stays an auditable // channel property that only an administrator edit can change. if plugin.Meta.BaseURL == "" { return fmt.Errorf("base URL is required for task plugin channels") } defaultBaseURL := plugin.Meta.BaseURL channel.BaseURL = &defaultBaseURL } } setting := channel.GetSetting() if channel.Type != constant.ChannelTypeNewAPI && len(setting.TaskExtendPluginKeys) > 0 { return fmt.Errorf("task_extend_plugin_keys is only supported on New API channels") } if channel.Type == constant.ChannelTypeNewAPI { if len(setting.TaskExtendPluginKeys) > maxTaskExtendPluginKeys { return fmt.Errorf("task_extend_plugin_keys must not exceed %d plugins", maxTaskExtendPluginKeys) } // The single key stays valid on a New API channel, so both bindings are // checked as one set. keys := setting.TaskExtendPluginKeys if setting.TaskPluginKey != "" { keys = append([]string{setting.TaskPluginKey}, keys...) } bound := make(map[string]struct{}, len(keys)) for _, key := range keys { if key == "" || key != strings.TrimSpace(key) { return fmt.Errorf("task plugin key %q is invalid", key) } if len(key) > 30 { return fmt.Errorf("task plugin key must not exceed 30 characters") } if _, duplicate := bound[key]; duplicate { return fmt.Errorf("task plugin %q is bound more than once", key) } plugin, ok := jsplugin.DefaultRegistry.Get(key) if !ok { return fmt.Errorf("task plugin %q is not registered", key) } if !plugin.Meta.SupportsUpstream(jsplugin.UpstreamKindNewAPI) { return fmt.Errorf("task plugin %q does not support a New API upstream and cannot be bound to a New API channel", key) } bound[key] = struct{}{} } } if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { return fmt.Errorf("New API channel base URL cannot be empty") } if channel.Type == constant.ChannelTypeVLLM && strings.TrimSpace(channel.GetBaseURL()) == "" { return fmt.Errorf("vLLM channel base URL cannot be empty") } if channel.Type == constant.ChannelTypeSGLang && strings.TrimSpace(channel.GetBaseURL()) == "" { return fmt.Errorf("SGLang channel base URL cannot be empty") } // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { if channel.Key == "" { return fmt.Errorf("channel cannot be empty") } // 检查模型名称长度是否超过 255 for _, m := range channel.GetModels() { if len(m) > 255 { return common.NewMessage("Model name is too long: {{model}}", map[string]any{"model": m}) } } } // VertexAI 特殊校验 if channel.Type == constant.ChannelTypeVertexAi { if channel.Other == "" { return common.NewMessage("Deployment region cannot be empty") } regionMap, err := common.StrToMap(channel.Other) if err != nil { return common.NewMessage("Deployment region must be valid JSON, for example {{example}}", map[string]any{"example": `{"default": "us-central1", "region2": "us-east1"}`}) } if regionMap["default"] == nil { return common.NewMessage("Deployment region must include a default field") } } // Codex OAuth key validation (optional, only when JSON object is provided) if channel.Type == constant.ChannelTypeCodex { trimmedKey := strings.TrimSpace(channel.Key) if isAdd || trimmedKey != "" { if !strings.HasPrefix(trimmedKey, "{") { return fmt.Errorf("Codex key must be a valid JSON object") } var keyMap map[string]any if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil { return fmt.Errorf("Codex key must be a valid JSON object") } if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { return fmt.Errorf("Codex key JSON must include access_token") } if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { return fmt.Errorf("Codex key JSON must include account_id") } } } return nil } func RefreshCodexChannelCredential(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) if err != nil { common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) if err != nil { common.SysError(common.LogText("failed to refresh codex channel credential: %s", err.Error())) common.ApiErrorT(c, "Failed to refresh credentials. Please try again later") return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "refreshed", "data": gin.H{ "expires_at": oauthKey.Expired, "last_refresh": oauthKey.LastRefresh, "account_id": oauthKey.AccountID, "email": oauthKey.Email, "channel_id": ch.Id, "channel_type": ch.Type, "channel_name": ch.Name, }, }) } type AddChannelRequest struct { Mode string `json:"mode"` MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"` Channel *model.Channel `json:"channel"` } func getVertexArrayKeys(keys string) ([]string, error) { if keys == "" { return nil, nil } var keyArray []any err := common.Unmarshal([]byte(keys), &keyArray) if err != nil { return nil, common.NewMessage("Batch-adding Vertex AI keys requires a JSON array, for example {{example}}. Check the input: {{error}}", map[string]any{"example": "[{key1}, {key2}...]", "error": err.Error()}) } cleanKeys := make([]string, 0, len(keyArray)) for _, key := range keyArray { var keyStr string switch v := key.(type) { case string: keyStr = strings.TrimSpace(v) default: bytes, err := common.Marshal(v) if err != nil { return nil, common.NewMessage("Failed to encode Vertex AI key JSON: {{error}}", map[string]any{"error": err.Error()}) } keyStr = string(bytes) } if keyStr != "" { cleanKeys = append(cleanKeys, keyStr) } } if len(cleanKeys) == 0 { return nil, common.NewMessage("Vertex AI keys for batch adding cannot be empty") } return cleanKeys, nil } func AddChannel(c *gin.Context) { addChannelRequest := AddChannelRequest{} err := c.ShouldBindJSON(&addChannelRequest) if err != nil { common.ApiError(c, err) return } // Binding a plugin needs the same permission on a New API channel as the // type-61 channel dedicated to it. if addChannelRequest.Channel != nil && (addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin || len(addChannelRequest.Channel.GetSetting().TaskPluginBindings()) > 0) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "task plugin channels require the task_plugin.bind permission", }) return } baseURLFromPluginDefault := addChannelRequest.Channel != nil && addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin && (addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "") // 使用统一的校验函数 if err := validateChannel(addChannelRequest.Channel, true); err != nil { common.ApiError(c, err) return } addChannelRequest.Channel.CreatedTime = common.GetTimestamp() keys := make([]string, 0) switch addChannelRequest.Mode { case "multi_to_single": addChannelRequest.Channel.ChannelInfo.IsMultiKey = true addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { common.ApiError(c, err) return } addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array) addChannelRequest.Channel.Key = strings.Join(array, "\n") } else { cleanKeys := make([]string, 0) for key := range strings.SplitSeq(addChannelRequest.Channel.Key, "\n") { if key == "" { continue } key = strings.TrimSpace(key) cleanKeys = append(cleanKeys, key) } addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys) addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n") } keys = []string{addChannelRequest.Channel.Key} case "batch": if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // multi json keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { common.ApiError(c, err) return } } else { keys = strings.Split(addChannelRequest.Channel.Key, "\n") } case "single": keys = []string{addChannelRequest.Channel.Key} default: common.ApiErrorT(c, "Unsupported add mode") return } channels := make([]model.Channel, 0, len(keys)) for _, key := range keys { if key == "" { continue } localChannel := addChannelRequest.Channel localChannel.Key = key if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 { keyPrefix := localChannel.Key if len(localChannel.Key) > 8 { keyPrefix = localChannel.Key[:8] } localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix) } channels = append(channels, *localChannel) } err = model.BatchInsertChannels(channels) if err != nil { common.ApiError(c, err) return } createAudit := map[string]any{ "name": addChannelRequest.Channel.Name, "type": addChannelRequest.Channel.Type, "count": len(channels), } if baseURLFromPluginDefault { createAudit["base_url_source"] = "plugin_default" } recordManageAudit(c, "channel.create", createAudit) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } func DeleteChannel(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) channelName := "" channelProxy := "" channelLookupFailed := false if existing, err := model.GetChannelById(id, false); err == nil && existing != nil { channelName = existing.Name channelProxy = existing.GetSetting().Proxy } else { channelLookupFailed = true } channel := model.Channel{Id: id} err := channel.Delete() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if channelLookupFailed { service.ResetProxyClientCache() } else { service.InvalidateProxyClient(channelProxy) } recordManageAudit(c, "channel.delete", map[string]any{ "id": id, "name": channelName, }) closeActiveChannelWebSockets([]int{id}) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } func DeleteDisabledChannel(c *gin.Context) { var ids []int if err := model.DB.Model(&model.Channel{}). Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled). Pluck("id", &ids).Error; err != nil { common.ApiError(c, err) return } rows, err := model.DeleteDisabledChannel() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if rows > 0 { service.ResetProxyClientCache() } recordManageAudit(c, "channel.delete_disabled", map[string]any{ "count": rows, }) closeActiveChannelWebSockets(ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": rows, }) return } type ChannelTag struct { Tag string `json:"tag"` NewTag *string `json:"new_tag"` Priority *int64 `json:"priority"` Weight *uint `json:"weight"` ModelMapping *string `json:"model_mapping"` Models *string `json:"models"` Groups *string `json:"groups"` ParamOverride *string `json:"param_override"` HeaderOverride *string `json:"header_override"` } func DisableTagChannels(c *gin.Context) { channelTag := ChannelTag{} err := c.ShouldBindJSON(&channelTag) if err != nil || channelTag.Tag == "" { common.ApiErrorT(c, "Invalid parameters") return } channels, err := model.GetChannelsByTag(channelTag.Tag, false, false) if err != nil { common.ApiError(c, err) return } ids := channelIDsFromChannels(channels) err = model.DisableChannelByTag(channelTag.Tag) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() recordManageAudit(c, "channel.tag_disable", map[string]any{ "tag": channelTag.Tag, }) closeActiveChannelWebSockets(ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } func EnableTagChannels(c *gin.Context) { channelTag := ChannelTag{} err := c.ShouldBindJSON(&channelTag) if err != nil || channelTag.Tag == "" { common.ApiErrorT(c, "Invalid parameters") return } err = model.EnableChannelByTag(channelTag.Tag) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() recordManageAudit(c, "channel.tag_enable", map[string]any{ "tag": channelTag.Tag, }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } func EditTagChannels(c *gin.Context) { channelTag := ChannelTag{} err := c.ShouldBindJSON(&channelTag) if err != nil { common.ApiErrorT(c, "Invalid parameters") return } if channelTag.Tag == "" { common.ApiErrorT(c, "Tag cannot be empty") return } if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { common.ApiErrorT(c, "Unauthorized, insufficient privileges") return } if channelTag.ParamOverride != nil { trimmed := strings.TrimSpace(*channelTag.ParamOverride) if trimmed != "" && !json.Valid([]byte(trimmed)) { common.ApiErrorT(c, "Parameter override must be valid JSON format") return } channelTag.ParamOverride = common.GetPointer[string](trimmed) } if channelTag.HeaderOverride != nil { trimmed := strings.TrimSpace(*channelTag.HeaderOverride) if trimmed != "" && !json.Valid([]byte(trimmed)) { common.ApiErrorT(c, "Header override must be valid JSON format") return } channelTag.HeaderOverride = common.GetPointer[string](trimmed) } err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() recordManageAudit(c, "channel.tag_edit", map[string]any{ "tag": channelTag.Tag, }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } type ChannelBatch struct { Ids []int `json:"ids"` Tag *string `json:"tag"` } func DeleteChannelBatch(c *gin.Context) { channelBatch := ChannelBatch{} err := c.ShouldBindJSON(&channelBatch) if err != nil || len(channelBatch.Ids) == 0 { common.ApiErrorT(c, "Invalid parameters") return } deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if deletedCount > 0 { service.ResetProxyClientCache() } recordManageAudit(c, "channel.delete_batch", map[string]any{ "count": deletedCount, }) closeActiveChannelWebSockets(channelBatch.Ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": deletedCount, }) return } type PatchChannel struct { model.Channel MultiKeyMode *string `json:"multi_key_mode"` KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 } type ChannelStatusRequest struct { Status int `json:"status"` } type ChannelStatusBatchRequest struct { Ids []int `json:"ids"` Status int `json:"status"` } func UpdateChannel(c *gin.Context) { channel := PatchChannel{} rawBody, err := c.GetRawData() if err != nil { common.ApiError(c, err) return } if err := common.Unmarshal(rawBody, &channel); err != nil { common.ApiError(c, err) return } var requestData map[string]any if err := common.Unmarshal(rawBody, &requestData); err != nil { common.ApiError(c, err) return } if _, ok := requestData["status"]; ok { common.ApiErrorT(c, "Invalid parameters") return } clearChannelReadOnlyFields(&channel, requestData) if channel.Type == constant.ChannelTypeTaskPlugin && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "task plugin channels require the task_plugin.bind permission", }) return } baseURLFromPluginDefault := channel.Type == constant.ChannelTypeTaskPlugin && (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") // 使用统一的校验函数 if err := validateChannel(&channel.Channel, false); err != nil { common.ApiError(c, err) return } // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. originChannel, err := model.GetChannelById(channel.Id, true) if err != nil { common.ApiError(c, err) return } originProxy := originChannel.GetSetting().Proxy proxyChanged := false _, settingProvided := requestData["setting"] if settingProvided { newProxy, _ := service.NormalizeProxyURL(channel.GetSetting().Proxy) normalizedOriginProxy, originProxyErr := service.NormalizeProxyURL(originProxy) proxyChanged = originProxyErr != nil || normalizedOriginProxy != newProxy } // Changing which plugins a channel binds needs the bind permission on any // channel type; resubmitting an unchanged New API binding list does not, so // administrators without it can still edit the rest of a gateway channel. if settingProvided && !slices.Equal(channel.GetSetting().TaskPluginBindings(), originChannel.GetSetting().TaskPluginBindings()) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "task plugin channels require the task_plugin.bind permission", }) return } // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. channel.ChannelInfo = originChannel.ChannelInfo if channelHasSensitiveChanges(&channel, originChannel, requestData) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { common.ApiErrorT(c, "Unauthorized, insufficient privileges") return } // If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info. if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) } // 处理多key模式下的密钥追加/覆盖逻辑 if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { switch *channel.KeyMode { case "append": // 追加模式:将新密钥添加到现有密钥列表 if originChannel.Key != "" { var newKeys []string var existingKeys []string // 解析现有密钥 if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") { // JSON数组格式 var arr []json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil { existingKeys = make([]string, len(arr)) for i, v := range arr { existingKeys[i] = string(v) } } } else { // 换行分隔格式 existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n") } // 处理 Vertex AI 的特殊情况 if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // 尝试解析新密钥为JSON数组 if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { array, err := getVertexArrayKeys(channel.Key) if err != nil { common.ApiErrorT(c, "Failed to parse appended keys: {{error}}", map[string]any{"error": err.Error()}) return } newKeys = array } else { // 单个JSON密钥 newKeys = []string{channel.Key} } } else { // 普通渠道的处理 inputKeys := strings.SplitSeq(channel.Key, "\n") for key := range inputKeys { key = strings.TrimSpace(key) if key != "" { newKeys = append(newKeys, key) } } } seen := make(map[string]struct{}, len(existingKeys)+len(newKeys)) for _, key := range existingKeys { normalized := strings.TrimSpace(key) if normalized == "" { continue } seen[normalized] = struct{}{} } dedupedNewKeys := make([]string, 0, len(newKeys)) for _, key := range newKeys { normalized := strings.TrimSpace(key) if normalized == "" { continue } if _, ok := seen[normalized]; ok { continue } seen[normalized] = struct{}{} dedupedNewKeys = append(dedupedNewKeys, normalized) } allKeys := append(existingKeys, dedupedNewKeys...) channel.Key = strings.Join(allKeys, "\n") } case "replace": // 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理) } } err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if proxyChanged { service.InvalidateProxyClient(originProxy) } // 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。 changedFields := make([]string, 0) if channel.Models != originChannel.Models { changedFields = append(changedFields, "models") } if channel.Group != originChannel.Group { changedFields = append(changedFields, "group") } if channel.Type != originChannel.Type { changedFields = append(changedFields, "type") } if !equalStringPtr(channel.BaseURL, originChannel.BaseURL) { changedFields = append(changedFields, "base_url") } if channel.Key != "" && channel.Key != originChannel.Key { changedFields = append(changedFields, "key") } updateAudit := map[string]any{ "id": channel.Id, "name": channel.Name, "changed_fields": changedFields, } if baseURLFromPluginDefault { updateAudit["base_url_source"] = "plugin_default" } recordManageAudit(c, "channel.update", updateAudit) channel.Key = "" clearChannelInfo(&channel.Channel) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": channel, }) return } func UpdateChannelStatus(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { common.ApiErrorT(c, "Invalid parameters") return } req := ChannelStatusRequest{} if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) { common.ApiErrorT(c, "Invalid parameters") return } changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") if changed { model.InitChannelCache() if req.Status != common.ChannelStatusEnabled { closeActiveChannelWebSockets([]int{id}) } } recordManageAudit(c, "channel.status_update", map[string]any{ "id": id, "status": req.Status, "changed": changed, }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": changed, }) } func BatchUpdateChannelStatus(c *gin.Context) { req := ChannelStatusBatchRequest{} if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) { common.ApiErrorT(c, "Invalid parameters") return } changedCount := 0 var disabledIDs []int for _, id := range req.Ids { if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") { changedCount++ if req.Status != common.ChannelStatusEnabled { disabledIDs = append(disabledIDs, id) } } } if changedCount > 0 { model.InitChannelCache() } if len(disabledIDs) > 0 { closeActiveChannelWebSockets(disabledIDs) } recordManageAudit(c, "channel.status_update_batch", map[string]any{ "count": changedCount, "total": len(req.Ids), "status": req.Status, }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": changedCount, }) } func isManageableChannelStatus(status int) bool { return status == common.ChannelStatusEnabled || status == common.ChannelStatusManuallyDisabled } // equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。 func equalStringPtr(a, b *string) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } type fetchModelsRequest struct { ChannelID int `json:"channel_id"` BaseURL *string `json:"base_url"` Type int `json:"type"` Key string `json:"key"` AdvancedCustom *string `json:"advanced_custom"` HeaderOverride *string `json:"header_override"` Proxy *string `json:"proxy"` } func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Channel, error) { var channel *model.Channel if req.ChannelID > 0 { savedChannel, err := model.GetChannelById(req.ChannelID, true) if err != nil { return nil, err } if savedChannel.Type != constant.ChannelTypeAdvancedCustom { return nil, fmt.Errorf("channel %d is not an advanced custom channel", req.ChannelID) } channel = savedChannel } else { key := strings.TrimSpace(req.Key) if key != "" { key = strings.Split(key, "\n")[0] } channel = &model.Channel{ Type: req.Type, Key: key, } } if channel.Type != constant.ChannelTypeAdvancedCustom { return nil, fmt.Errorf("channel type must be advanced custom") } if req.BaseURL != nil { baseURL := strings.TrimSpace(*req.BaseURL) channel.BaseURL = &baseURL } settings := channel.GetOtherSettings() if req.AdvancedCustom != nil { rawConfig := strings.TrimSpace(*req.AdvancedCustom) if rawConfig == "" { return nil, fmt.Errorf("advanced_custom is required") } var config dto.AdvancedCustomConfig if err := common.UnmarshalJsonStr(rawConfig, &config); err != nil { return nil, err } settings.AdvancedCustom = &config } else if req.ChannelID <= 0 { return nil, fmt.Errorf("advanced_custom is required") } channel.SetOtherSettings(settings) if req.HeaderOverride != nil { rawHeaderOverride := strings.TrimSpace(*req.HeaderOverride) if rawHeaderOverride != "" { var headerOverride map[string]any if err := common.UnmarshalJsonStr(rawHeaderOverride, &headerOverride); err != nil { return nil, fmt.Errorf("header_override must be a JSON object: %w", err) } } channel.HeaderOverride = &rawHeaderOverride } if req.Proxy != nil { channelSettings := channel.GetSetting() channelSettings.Proxy = strings.TrimSpace(*req.Proxy) channel.SetSetting(channelSettings) } if err := validateChannel(channel, false); err != nil { return nil, err } return channel, nil } func FetchModels(c *gin.Context) { var req fetchModelsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Invalid request", }) return } var channel *model.Channel if req.Type == constant.ChannelTypeAdvancedCustom || req.ChannelID > 0 { var err error channel, err = buildAdvancedCustomModelPreviewChannel(req) if err != nil { common.ApiError(c, err) return } } else { baseURL := "" if req.BaseURL != nil { baseURL = strings.TrimSpace(*req.BaseURL) } if baseURL == "" { baseURL = constant.GetChannelBaseURL(req.Type) } key := strings.TrimSpace(req.Key) if req.Type != constant.ChannelTypeCodex { key = strings.Split(key, "\n")[0] } channel = &model.Channel{ Type: req.Type, Key: key, BaseURL: &baseURL, } } models, err := fetchChannelUpstreamModelIDs(channel) if err != nil { common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": models, }) } func BatchSetChannelTag(c *gin.Context) { channelBatch := ChannelBatch{} err := c.ShouldBindJSON(&channelBatch) if err != nil || len(channelBatch.Ids) == 0 { common.ApiErrorT(c, "Invalid parameters") return } err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() recordManageAudit(c, "channel.tag_batch_set", map[string]any{ "count": len(channelBatch.Ids), }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": len(channelBatch.Ids), }) return } func GetTagModels(c *gin.Context) { tag := c.Query("tag") if tag == "" { common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Tag cannot be empty")) return } channels, err := model.GetChannelsByTag(tag, false, false) // idSort=false, selectAll=false if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": err.Error(), }) return } var longestModels string maxLength := 0 // Find the longest models string among all channels with the given tag for _, channel := range channels { if channel.Models != "" { currentModels := strings.Split(channel.Models, ",") if len(currentModels) > maxLength { maxLength = len(currentModels) longestModels = channel.Models } } } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": longestModels, }) return } // CopyChannel handles cloning an existing channel with its key. // POST /api/channel/copy/:id // Optional query params: // // suffix - string appended to the original name (default "_copy") // reset_balance - bool, when true will reset balance & used_quota to 0 (default true) func CopyChannel(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"}) return } suffix := c.DefaultQuery("suffix", "_copy") resetBalance := true if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" { if v, err := strconv.ParseBool(rbStr); err == nil { resetBalance = v } } // fetch original channel with key origin, err := model.GetChannelById(id, true) if err != nil { common.SysError(common.LogText("failed to get channel by id: %s", err.Error())) common.ApiErrorT(c, "Failed to fetch channel. Please try again later") return } if (origin.Type == constant.ChannelTypeTaskPlugin || len(origin.GetSetting().TaskPluginBindings()) > 0) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"}) return } // clone channel clone := *origin // shallow copy is sufficient as we will overwrite primitives clone.Id = 0 // let DB auto-generate clone.CreatedTime = common.GetTimestamp() clone.Name = origin.Name + suffix clone.TestTime = 0 clone.ResponseTime = 0 if resetBalance { clone.Balance = 0 clone.UsedQuota = 0 } if err := clone.ValidateSettings(); err != nil { common.SysError(common.LogText("failed to validate cloned channel: %s", err.Error())) c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"}) return } // insert if err := clone.Insert(); err != nil { common.SysError(common.LogText("failed to clone channel: %s", err.Error())) common.ApiErrorT(c, "Failed to copy channel. Please try again later") return } model.InitChannelCache() recordManageAudit(c, "channel.copy", map[string]any{ "sourceId": id, "id": clone.Id, "name": clone.Name, }) // success c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) } // MultiKeyManageRequest represents the request for multi-key management operations type MultiKeyManageRequest struct { ChannelId int `json:"channel_id"` Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status" KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions Page int `json:"page,omitempty"` // for get_key_status pagination PageSize int `json:"page_size,omitempty"` // for get_key_status pagination Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all } // MultiKeyStatusResponse represents the response for key status query type MultiKeyStatusResponse struct { Keys []KeyStatus `json:"keys"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` TotalPages int `json:"total_pages"` // Statistics EnabledCount int `json:"enabled_count"` ManualDisabledCount int `json:"manual_disabled_count"` AutoDisabledCount int `json:"auto_disabled_count"` } type KeyStatus struct { Index int `json:"index"` Status int `json:"status"` // 1: enabled, 2: disabled DisabledTime int64 `json:"disabled_time,omitempty"` Reason string `json:"reason,omitempty"` KeyPreview string `json:"key_preview"` // first 10 chars of key for identification } // ManageMultiKeys handles multi-key management operations func ManageMultiKeys(c *gin.Context) { request := MultiKeyManageRequest{} err := c.ShouldBindJSON(&request) if err != nil { common.ApiError(c, err) return } channel, err := model.GetChannelById(request.ChannelId, true) if err != nil { common.ApiErrorT(c, "Channel does not exist") return } if !channel.ChannelInfo.IsMultiKey { common.ApiErrorT(c, "This channel is not in multi-key mode") return } if multiKeyActionRequiresSensitiveWrite(request.Action) && !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { common.ApiErrorT(c, "Unauthorized, insufficient privileges") return } // get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。 if request.Action == "get_key_status" { markAuditLogged(c) } else { recordManageAudit(c, "channel.multi_key_manage", map[string]any{ "action": request.Action, "id": channel.Id, }) } lock := model.GetChannelPollingLock(channel.Id) lock.Lock() defer lock.Unlock() switch request.Action { case "get_key_status": keys := channel.GetKeys() // Default pagination parameters page := request.Page pageSize := request.PageSize if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 50 // Default page size } // Statistics for all keys (unchanged by filtering) var enabledCount, manualDisabledCount, autoDisabledCount int // Build all key status data first var allKeyStatusList []KeyStatus for i, key := range keys { status := 1 // default enabled var disabledTime int64 var reason string if channel.ChannelInfo.MultiKeyStatusList != nil { if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { status = s } } // Count for statistics (all keys) switch status { case 1: enabledCount++ case 2: manualDisabledCount++ case 3: autoDisabledCount++ } if status != 1 { if channel.ChannelInfo.MultiKeyDisabledTime != nil { disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] } if channel.ChannelInfo.MultiKeyDisabledReason != nil { reason = channel.ChannelInfo.MultiKeyDisabledReason[i] } } // Create key preview (first 10 chars) keyPreview := key if len(key) > 10 { keyPreview = key[:10] + "..." } allKeyStatusList = append(allKeyStatusList, KeyStatus{ Index: i, Status: status, DisabledTime: disabledTime, Reason: reason, KeyPreview: keyPreview, }) } // Apply status filter if specified var filteredKeyStatusList []KeyStatus if request.Status != nil { for _, keyStatus := range allKeyStatusList { if keyStatus.Status == *request.Status { filteredKeyStatusList = append(filteredKeyStatusList, keyStatus) } } } else { filteredKeyStatusList = allKeyStatusList } // Calculate pagination based on filtered results filteredTotal := len(filteredKeyStatusList) totalPages := (filteredTotal + pageSize - 1) / pageSize if totalPages == 0 { totalPages = 1 } if page > totalPages { page = totalPages } // Calculate range for current page start := (page - 1) * pageSize end := min(start+pageSize, filteredTotal) // Get the page data var pageKeyStatusList []KeyStatus if start < filteredTotal { pageKeyStatusList = filteredKeyStatusList[start:end] } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": MultiKeyStatusResponse{ Keys: pageKeyStatusList, Total: filteredTotal, // Total of filtered results Page: page, PageSize: pageSize, TotalPages: totalPages, EnabledCount: enabledCount, // Overall statistics ManualDisabledCount: manualDisabledCount, // Overall statistics AutoDisabledCount: autoDisabledCount, // Overall statistics }, }) return case "disable_key": if request.KeyIndex == nil { common.ApiErrorT(c, "No key index specified to disable") return } keyIndex := *request.KeyIndex if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { common.ApiErrorT(c, "Key index out of range") return } if channel.ChannelInfo.MultiKeyStatusList == nil { channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) } if channel.ChannelInfo.MultiKeyDisabledTime == nil { channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) } if channel.ChannelInfo.MultiKeyDisabledReason == nil { channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) } channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if shouldCloseWebSocket { closeActiveChannelWebSockets([]int{channel.Id}) } common.ApiSuccessT(c, "Key disabled", nil) return case "enable_key": if request.KeyIndex == nil { common.ApiErrorT(c, "No key index specified to enable") return } keyIndex := *request.KeyIndex if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { common.ApiErrorT(c, "Key index out of range") return } // 从状态列表中删除该密钥的记录,使其回到默认启用状态 if channel.ChannelInfo.MultiKeyStatusList != nil { delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) } if channel.ChannelInfo.MultiKeyDisabledTime != nil { delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex) } if channel.ChannelInfo.MultiKeyDisabledReason != nil { delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) } restoreMultiKeyChannelIfAvailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() common.ApiSuccessT(c, "Key enabled", nil) return case "enable_all_keys": // 清空所有禁用状态,使所有密钥回到默认启用状态 var enabledCount int if channel.ChannelInfo.MultiKeyStatusList != nil { enabledCount = len(channel.ChannelInfo.MultiKeyStatusList) } channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) restoreMultiKeyChannelIfAvailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() common.ApiSuccessT(c, "Enabled {{count}} keys", nil, map[string]any{"count": enabledCount}) return case "disable_all_keys": // 禁用所有启用的密钥 if channel.ChannelInfo.MultiKeyStatusList == nil { channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) } if channel.ChannelInfo.MultiKeyDisabledTime == nil { channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) } if channel.ChannelInfo.MultiKeyDisabledReason == nil { channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) } var disabledCount int for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ { status := 1 // default enabled if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { status = s } // 只禁用当前启用的密钥 if status == 1 { channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled disabledCount++ } } if disabledCount == 0 { common.ApiErrorT(c, "No keys to disable") return } shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if shouldCloseWebSocket { closeActiveChannelWebSockets([]int{channel.Id}) } common.ApiSuccessT(c, "Disabled {{count}} keys", nil, map[string]any{"count": disabledCount}) return case "delete_key": if request.KeyIndex == nil { common.ApiErrorT(c, "No key index specified to delete") return } keyIndex := *request.KeyIndex if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { common.ApiErrorT(c, "Key index out of range") return } keys := channel.GetKeys() var remainingKeys []string var newStatusList = make(map[int]int) var newDisabledTime = make(map[int]int64) var newDisabledReason = make(map[int]string) newIndex := 0 for i, key := range keys { // 跳过要删除的密钥 if i == keyIndex { continue } remainingKeys = append(remainingKeys, key) // 保留其他密钥的状态信息,重新索引 if channel.ChannelInfo.MultiKeyStatusList != nil { if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 { newStatusList[newIndex] = status } } if channel.ChannelInfo.MultiKeyDisabledTime != nil { if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { newDisabledTime[newIndex] = t } } if channel.ChannelInfo.MultiKeyDisabledReason != nil { if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { newDisabledReason[newIndex] = r } } newIndex++ } if len(remainingKeys) == 0 { common.ApiErrorT(c, "Cannot delete the last key") return } // Update channel with remaining keys channel.Key = strings.Join(remainingKeys, "\n") channel.ChannelInfo.MultiKeySize = len(remainingKeys) channel.ChannelInfo.MultiKeyStatusList = newStatusList channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if shouldCloseWebSocket { closeActiveChannelWebSockets([]int{channel.Id}) } common.ApiSuccessT(c, "Key deleted", nil) return case "delete_disabled_keys": keys := channel.GetKeys() var remainingKeys []string var deletedCount int var newStatusList = make(map[int]int) var newDisabledTime = make(map[int]int64) var newDisabledReason = make(map[int]string) newIndex := 0 for i, key := range keys { status := 1 // default enabled if channel.ChannelInfo.MultiKeyStatusList != nil { if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { status = s } } // 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥 if status == 3 { deletedCount++ } else { remainingKeys = append(remainingKeys, key) // 保留非自动禁用密钥的状态信息,重新索引 if status != 1 { newStatusList[newIndex] = status if channel.ChannelInfo.MultiKeyDisabledTime != nil { if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { newDisabledTime[newIndex] = t } } if channel.ChannelInfo.MultiKeyDisabledReason != nil { if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { newDisabledReason[newIndex] = r } } } newIndex++ } } if deletedCount == 0 { common.ApiErrorT(c, "No auto-disabled keys to delete") return } // Update channel with remaining keys channel.Key = strings.Join(remainingKeys, "\n") channel.ChannelInfo.MultiKeySize = len(remainingKeys) channel.ChannelInfo.MultiKeyStatusList = newStatusList channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } model.InitChannelCache() if shouldCloseWebSocket { closeActiveChannelWebSockets([]int{channel.Id}) } common.ApiSuccessT(c, "Deleted {{count}} auto-disabled keys", deletedCount, map[string]any{"count": deletedCount}) return default: common.ApiErrorT(c, "Unsupported operation") return } } func multiKeyActionRequiresSensitiveWrite(action string) bool { return action == "delete_key" || action == "delete_disabled_keys" } // OllamaPullModel 拉取 Ollama 模型 func OllamaPullModel(c *gin.Context) { var req struct { ChannelID int `json:"channel_id"` ModelName string `json:"model_name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Invalid request parameters", }) return } if req.ChannelID == 0 || req.ModelName == "" { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Channel ID and model name are required", }) return } // 获取渠道信息 channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ "success": false, "message": "Channel not found", }) return } // 检查是否是 Ollama 渠道 if channel.Type != constant.ChannelTypeOllama { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "This operation is only supported for Ollama channels", }) return } baseURL := constant.GetChannelBaseURL(channel.Type) if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } key := strings.Split(channel.Key, "\n")[0] err = ollama.PullOllamaModel(baseURL, key, req.ModelName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": fmt.Sprintf("Failed to pull model: %s", err.Error()), }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": fmt.Sprintf("Model %s pulled successfully", req.ModelName), }) } // OllamaPullModelStream 流式拉取 Ollama 模型 func OllamaPullModelStream(c *gin.Context) { var req struct { ChannelID int `json:"channel_id"` ModelName string `json:"model_name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Invalid request parameters", }) return } if req.ChannelID == 0 || req.ModelName == "" { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Channel ID and model name are required", }) return } // 获取渠道信息 channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ "success": false, "message": "Channel not found", }) return } // 检查是否是 Ollama 渠道 if channel.Type != constant.ChannelTypeOllama { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "This operation is only supported for Ollama channels", }) return } baseURL := constant.GetChannelBaseURL(channel.Type) if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } // 设置 SSE 头部 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("Access-Control-Allow-Origin", "*") key := strings.Split(channel.Key, "\n")[0] // 创建进度回调函数 progressCallback := func(progress ollama.OllamaPullResponse) { data, _ := json.Marshal(progress) fmt.Fprintf(c.Writer, "data: %s\n\n", string(data)) c.Writer.Flush() } // 执行拉取 err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback) if err != nil { errorData, _ := json.Marshal(gin.H{ "error": err.Error(), }) fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData)) } else { successData, _ := json.Marshal(gin.H{ "message": fmt.Sprintf("Model %s pulled successfully", req.ModelName), }) fmt.Fprintf(c.Writer, "data: %s\n\n", string(successData)) } // 发送结束标志 fmt.Fprintf(c.Writer, "data: [DONE]\n\n") c.Writer.Flush() } // OllamaDeleteModel 删除 Ollama 模型 func OllamaDeleteModel(c *gin.Context) { var req struct { ChannelID int `json:"channel_id"` ModelName string `json:"model_name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Invalid request parameters", }) return } if req.ChannelID == 0 || req.ModelName == "" { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Channel ID and model name are required", }) return } // 获取渠道信息 channel, err := model.GetChannelById(req.ChannelID, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ "success": false, "message": "Channel not found", }) return } // 检查是否是 Ollama 渠道 if channel.Type != constant.ChannelTypeOllama { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "This operation is only supported for Ollama channels", }) return } baseURL := constant.GetChannelBaseURL(channel.Type) if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } key := strings.Split(channel.Key, "\n")[0] err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": fmt.Sprintf("Failed to delete model: %s", err.Error()), }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": fmt.Sprintf("Model %s deleted successfully", req.ModelName), }) } // OllamaVersion 获取 Ollama 服务版本信息 func OllamaVersion(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "Invalid channel id", }) return } channel, err := model.GetChannelById(id, true) if err != nil { c.JSON(http.StatusNotFound, gin.H{ "success": false, "message": "Channel not found", }) return } if channel.Type != constant.ChannelTypeOllama { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "This operation is only supported for Ollama channels", }) return } baseURL := constant.GetChannelBaseURL(channel.Type) if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } key := strings.Split(channel.Key, "\n")[0] version, err := ollama.FetchOllamaVersion(baseURL, key) if err != nil { common.ApiErrorT(c, "Failed to fetch Ollama version: {{error}}", map[string]any{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "version": version, }, }) }