Forráskód Böngészése

feat: auto fetch upstream models (#2979)

* feat: add upstream model update detection with scheduled sync and manual apply flows

* feat: support upstream model removal sync and selectable deletes in update modal

* feat: add detect-only upstream updates and show compact +/- model badges

* feat: improve upstream model update UX

* feat: improve upstream model update UX

* fix: respect model_mapping in upstream update detection

* feat: improve upstream update modal to prevent missed add/remove actions

* feat: add admin upstream model update notifications with digest and truncation

* fix: avoid repeated partial-submit confirmation in upstream update modal

* feat: improve ui/ux

* feat: suppress upstream update alerts for unchanged channel-count within 24h

* fix: submit upstream update choices even when no models are selected

* feat: improve upstream model update flow and split frontend updater

* fix merge conflict
Seefs 4 napja
szülő
commit
e71f5a45f2

+ 2 - 145
controller/channel.go

@@ -209,158 +209,15 @@ func FetchUpstreamModels(c *gin.Context) {
 		return
 	}
 
-	baseURL := constant.ChannelBaseURLs[channel.Type]
-	if channel.GetBaseURL() != "" {
-		baseURL = channel.GetBaseURL()
-	}
-
-	// 对于 Ollama 渠道,使用特殊处理
-	if channel.Type == constant.ChannelTypeOllama {
-		key := strings.Split(channel.Key, "\n")[0]
-		models, err := ollama.FetchOllamaModels(baseURL, key)
-		if err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
-			})
-			return
-		}
-
-		result := OpenAIModelsResponse{
-			Data: make([]OpenAIModel, 0, len(models)),
-		}
-
-		for _, modelInfo := range models {
-			metadata := map[string]any{}
-			if modelInfo.Size > 0 {
-				metadata["size"] = modelInfo.Size
-			}
-			if modelInfo.Digest != "" {
-				metadata["digest"] = modelInfo.Digest
-			}
-			if modelInfo.ModifiedAt != "" {
-				metadata["modified_at"] = modelInfo.ModifiedAt
-			}
-			details := modelInfo.Details
-			if details.ParentModel != "" || details.Format != "" || details.Family != "" || len(details.Families) > 0 || details.ParameterSize != "" || details.QuantizationLevel != "" {
-				metadata["details"] = modelInfo.Details
-			}
-			if len(metadata) == 0 {
-				metadata = nil
-			}
-
-			result.Data = append(result.Data, OpenAIModel{
-				ID:       modelInfo.Name,
-				Object:   "model",
-				Created:  0,
-				OwnedBy:  "ollama",
-				Metadata: metadata,
-			})
-		}
-
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"data":    result.Data,
-		})
-		return
-	}
-
-	// 对于 Gemini 渠道,使用特殊处理
-	if channel.Type == constant.ChannelTypeGemini {
-		// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
-		key, _, apiErr := channel.GetNextEnabledKey()
-		if apiErr != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
-			})
-			return
-		}
-		key = strings.TrimSpace(key)
-		models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
-		if err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
-			})
-			return
-		}
-
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"message": "",
-			"data":    models,
-		})
-		return
-	}
-
-	var url string
-	switch channel.Type {
-	case constant.ChannelTypeAli:
-		url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
-	case constant.ChannelTypeZhipu_v4:
-		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
-			url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
-		} else {
-			url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
-		}
-	case constant.ChannelTypeVolcEngine:
-		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
-			url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
-		} else {
-			url = fmt.Sprintf("%s/v1/models", baseURL)
-		}
-	case constant.ChannelTypeMoonshot:
-		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
-			url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
-		} else {
-			url = fmt.Sprintf("%s/v1/models", baseURL)
-		}
-	default:
-		url = fmt.Sprintf("%s/v1/models", baseURL)
-	}
-
-	// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
-	key, _, apiErr := channel.GetNextEnabledKey()
-	if apiErr != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
-		})
-		return
-	}
-	key = strings.TrimSpace(key)
-
-	headers, err := buildFetchModelsHeaders(channel, key)
-	if err != nil {
-		common.ApiError(c, err)
-		return
-	}
-
-	body, err := GetResponseBody("GET", url, channel, headers)
+	ids, err := fetchChannelUpstreamModelIDs(channel)
 	if err != nil {
-		common.ApiError(c, err)
-		return
-	}
-
-	var result OpenAIModelsResponse
-	if err = json.Unmarshal(body, &result); err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": fmt.Sprintf("解析响应失败: %s", err.Error()),
+			"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
 		})
 		return
 	}
 
-	var ids []string
-	for _, model := range result.Data {
-		id := model.ID
-		if channel.Type == constant.ChannelTypeGemini {
-			id = strings.TrimPrefix(id, "models/")
-		}
-		ids = append(ids, id)
-	}
-
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",

+ 983 - 0
controller/channel_upstream_update.go

@@ -0,0 +1,983 @@
+package controller
+
+import (
+	"fmt"
+	"net/http"
+	"slices"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay/channel/gemini"
+	"github.com/QuantumNous/new-api/relay/channel/ollama"
+	"github.com/QuantumNous/new-api/service"
+
+	"github.com/gin-gonic/gin"
+	"github.com/samber/lo"
+)
+
+const (
+	channelUpstreamModelUpdateTaskDefaultIntervalMinutes  = 30
+	channelUpstreamModelUpdateTaskBatchSize               = 100
+	channelUpstreamModelUpdateMinCheckIntervalSeconds     = 300
+	channelUpstreamModelUpdateNotifySuppressWindowSeconds = 86400
+	channelUpstreamModelUpdateNotifyMaxChannelDetails     = 8
+	channelUpstreamModelUpdateNotifyMaxModelDetails       = 12
+	channelUpstreamModelUpdateNotifyMaxFailedChannelIDs   = 10
+)
+
+var (
+	channelUpstreamModelUpdateTaskOnce    sync.Once
+	channelUpstreamModelUpdateTaskRunning atomic.Bool
+	channelUpstreamModelUpdateNotifyState = struct {
+		sync.Mutex
+		lastNotifiedAt      int64
+		lastChangedChannels int
+		lastFailedChannels  int
+	}{}
+)
+
+type applyChannelUpstreamModelUpdatesRequest struct {
+	ID           int      `json:"id"`
+	AddModels    []string `json:"add_models"`
+	RemoveModels []string `json:"remove_models"`
+	IgnoreModels []string `json:"ignore_models"`
+}
+
+type applyAllChannelUpstreamModelUpdatesResult struct {
+	ChannelID             int      `json:"channel_id"`
+	ChannelName           string   `json:"channel_name"`
+	AddedModels           []string `json:"added_models"`
+	RemovedModels         []string `json:"removed_models"`
+	RemainingModels       []string `json:"remaining_models"`
+	RemainingRemoveModels []string `json:"remaining_remove_models"`
+}
+
+type detectChannelUpstreamModelUpdatesResult struct {
+	ChannelID       int      `json:"channel_id"`
+	ChannelName     string   `json:"channel_name"`
+	AddModels       []string `json:"add_models"`
+	RemoveModels    []string `json:"remove_models"`
+	LastCheckTime   int64    `json:"last_check_time"`
+	AutoAddedModels int      `json:"auto_added_models"`
+}
+
+type upstreamModelUpdateChannelSummary struct {
+	ChannelName string
+	AddCount    int
+	RemoveCount int
+}
+
+func normalizeModelNames(models []string) []string {
+	return lo.Uniq(lo.FilterMap(models, func(model string, _ int) (string, bool) {
+		trimmed := strings.TrimSpace(model)
+		return trimmed, trimmed != ""
+	}))
+}
+
+func mergeModelNames(base []string, appended []string) []string {
+	merged := normalizeModelNames(base)
+	seen := make(map[string]struct{}, len(merged))
+	for _, model := range merged {
+		seen[model] = struct{}{}
+	}
+	for _, model := range normalizeModelNames(appended) {
+		if _, ok := seen[model]; ok {
+			continue
+		}
+		seen[model] = struct{}{}
+		merged = append(merged, model)
+	}
+	return merged
+}
+
+func subtractModelNames(base []string, removed []string) []string {
+	removeSet := make(map[string]struct{}, len(removed))
+	for _, model := range normalizeModelNames(removed) {
+		removeSet[model] = struct{}{}
+	}
+	return lo.Filter(normalizeModelNames(base), func(model string, _ int) bool {
+		_, ok := removeSet[model]
+		return !ok
+	})
+}
+
+func intersectModelNames(base []string, allowed []string) []string {
+	allowedSet := make(map[string]struct{}, len(allowed))
+	for _, model := range normalizeModelNames(allowed) {
+		allowedSet[model] = struct{}{}
+	}
+	return lo.Filter(normalizeModelNames(base), func(model string, _ int) bool {
+		_, ok := allowedSet[model]
+		return ok
+	})
+}
+
+func applySelectedModelChanges(originModels []string, addModels []string, removeModels []string) []string {
+	// Add wins when the same model appears in both selected lists.
+	normalizedAdd := normalizeModelNames(addModels)
+	normalizedRemove := subtractModelNames(normalizeModelNames(removeModels), normalizedAdd)
+	return subtractModelNames(mergeModelNames(originModels, normalizedAdd), normalizedRemove)
+}
+
+func normalizeChannelModelMapping(channel *model.Channel) map[string]string {
+	if channel == nil || channel.ModelMapping == nil {
+		return nil
+	}
+	rawMapping := strings.TrimSpace(*channel.ModelMapping)
+	if rawMapping == "" || rawMapping == "{}" {
+		return nil
+	}
+	parsed := make(map[string]string)
+	if err := common.UnmarshalJsonStr(rawMapping, &parsed); err != nil {
+		return nil
+	}
+	normalized := make(map[string]string, len(parsed))
+	for source, target := range parsed {
+		normalizedSource := strings.TrimSpace(source)
+		normalizedTarget := strings.TrimSpace(target)
+		if normalizedSource == "" || normalizedTarget == "" {
+			continue
+		}
+		normalized[normalizedSource] = normalizedTarget
+	}
+	if len(normalized) == 0 {
+		return nil
+	}
+	return normalized
+}
+
+func collectPendingUpstreamModelChangesFromModels(
+	localModels []string,
+	upstreamModels []string,
+	ignoredModels []string,
+	modelMapping map[string]string,
+) (pendingAddModels []string, pendingRemoveModels []string) {
+	localSet := make(map[string]struct{})
+	localModels = normalizeModelNames(localModels)
+	upstreamModels = normalizeModelNames(upstreamModels)
+	for _, modelName := range localModels {
+		localSet[modelName] = struct{}{}
+	}
+	upstreamSet := make(map[string]struct{}, len(upstreamModels))
+	for _, modelName := range upstreamModels {
+		upstreamSet[modelName] = struct{}{}
+	}
+
+	ignoredSet := make(map[string]struct{})
+	for _, modelName := range normalizeModelNames(ignoredModels) {
+		ignoredSet[modelName] = struct{}{}
+	}
+
+	redirectSourceSet := make(map[string]struct{}, len(modelMapping))
+	redirectTargetSet := make(map[string]struct{}, len(modelMapping))
+	for source, target := range modelMapping {
+		redirectSourceSet[source] = struct{}{}
+		redirectTargetSet[target] = struct{}{}
+	}
+
+	coveredUpstreamSet := make(map[string]struct{}, len(localSet)+len(redirectTargetSet))
+	for modelName := range localSet {
+		coveredUpstreamSet[modelName] = struct{}{}
+	}
+	for modelName := range redirectTargetSet {
+		coveredUpstreamSet[modelName] = struct{}{}
+	}
+
+	pendingAdd := lo.Filter(upstreamModels, func(modelName string, _ int) bool {
+		if _, ok := coveredUpstreamSet[modelName]; ok {
+			return false
+		}
+		if _, ok := ignoredSet[modelName]; ok {
+			return false
+		}
+		return true
+	})
+	pendingRemove := lo.Filter(localModels, func(modelName string, _ int) bool {
+		// Redirect source models are virtual aliases and should not be removed
+		// only because they are absent from upstream model list.
+		if _, ok := redirectSourceSet[modelName]; ok {
+			return false
+		}
+		_, ok := upstreamSet[modelName]
+		return !ok
+	})
+	return normalizeModelNames(pendingAdd), normalizeModelNames(pendingRemove)
+}
+
+func collectPendingUpstreamModelChanges(channel *model.Channel, settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string, err error) {
+	upstreamModels, err := fetchChannelUpstreamModelIDs(channel)
+	if err != nil {
+		return nil, nil, err
+	}
+	pendingAddModels, pendingRemoveModels = collectPendingUpstreamModelChangesFromModels(
+		channel.GetModels(),
+		upstreamModels,
+		settings.UpstreamModelUpdateIgnoredModels,
+		normalizeChannelModelMapping(channel),
+	)
+	return pendingAddModels, pendingRemoveModels, nil
+}
+
+func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 {
+	interval := int64(common.GetEnvOrDefault(
+		"CHANNEL_UPSTREAM_MODEL_UPDATE_MIN_CHECK_INTERVAL_SECONDS",
+		channelUpstreamModelUpdateMinCheckIntervalSeconds,
+	))
+	if interval < 0 {
+		return channelUpstreamModelUpdateMinCheckIntervalSeconds
+	}
+	return interval
+}
+
+func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+
+	if channel.Type == constant.ChannelTypeOllama {
+		key := strings.TrimSpace(strings.Split(channel.Key, "\n")[0])
+		models, err := ollama.FetchOllamaModels(baseURL, key)
+		if err != nil {
+			return nil, err
+		}
+		return normalizeModelNames(lo.Map(models, func(item ollama.OllamaModel, _ int) string {
+			return item.Name
+		})), nil
+	}
+
+	if channel.Type == constant.ChannelTypeGemini {
+		key, _, apiErr := channel.GetNextEnabledKey()
+		if apiErr != nil {
+			return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
+		}
+		key = strings.TrimSpace(key)
+		models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
+		if err != nil {
+			return nil, err
+		}
+		return normalizeModelNames(models), nil
+	}
+
+	var url string
+	switch channel.Type {
+	case constant.ChannelTypeAli:
+		url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
+	case constant.ChannelTypeZhipu_v4:
+		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
+			url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
+		} else {
+			url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
+		}
+	case constant.ChannelTypeVolcEngine:
+		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
+			url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
+		} else {
+			url = fmt.Sprintf("%s/v1/models", baseURL)
+		}
+	case constant.ChannelTypeMoonshot:
+		if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
+			url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
+		} else {
+			url = fmt.Sprintf("%s/v1/models", baseURL)
+		}
+	default:
+		url = fmt.Sprintf("%s/v1/models", baseURL)
+	}
+
+	key, _, apiErr := channel.GetNextEnabledKey()
+	if apiErr != nil {
+		return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
+	}
+	key = strings.TrimSpace(key)
+
+	headers, err := buildFetchModelsHeaders(channel, key)
+	if err != nil {
+		return nil, err
+	}
+
+	body, err := GetResponseBody(http.MethodGet, url, channel, headers)
+	if err != nil {
+		return nil, err
+	}
+
+	var result OpenAIModelsResponse
+	if err := common.Unmarshal(body, &result); err != nil {
+		return nil, err
+	}
+
+	ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
+		if channel.Type == constant.ChannelTypeGemini {
+			return strings.TrimPrefix(item.ID, "models/")
+		}
+		return item.ID
+	})
+
+	return normalizeModelNames(ids), nil
+}
+
+func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error {
+	channel.SetOtherSettings(settings)
+	updates := map[string]interface{}{
+		"settings": channel.OtherSettings,
+	}
+	if updateModels {
+		updates["models"] = channel.Models
+	}
+	return model.DB.Model(&model.Channel{}).Where("id = ?", channel.Id).Updates(updates).Error
+}
+
+func checkAndPersistChannelUpstreamModelUpdates(
+	channel *model.Channel,
+	settings *dto.ChannelOtherSettings,
+	force bool,
+	allowAutoApply bool,
+) (modelsChanged bool, autoAdded int, err error) {
+	now := common.GetTimestamp()
+	if !force {
+		minInterval := getUpstreamModelUpdateMinCheckIntervalSeconds()
+		if settings.UpstreamModelUpdateLastCheckTime > 0 &&
+			now-settings.UpstreamModelUpdateLastCheckTime < minInterval {
+			return false, 0, nil
+		}
+	}
+
+	pendingAddModels, pendingRemoveModels, fetchErr := collectPendingUpstreamModelChanges(channel, *settings)
+	settings.UpstreamModelUpdateLastCheckTime = now
+	if fetchErr != nil {
+		if err = updateChannelUpstreamModelSettings(channel, *settings, false); err != nil {
+			return false, 0, err
+		}
+		return false, 0, fetchErr
+	}
+
+	if allowAutoApply && settings.UpstreamModelUpdateAutoSyncEnabled && len(pendingAddModels) > 0 {
+		originModels := normalizeModelNames(channel.GetModels())
+		mergedModels := mergeModelNames(originModels, pendingAddModels)
+		if len(mergedModels) > len(originModels) {
+			channel.Models = strings.Join(mergedModels, ",")
+			autoAdded = len(mergedModels) - len(originModels)
+			modelsChanged = true
+		}
+		settings.UpstreamModelUpdateLastDetectedModels = []string{}
+	} else {
+		settings.UpstreamModelUpdateLastDetectedModels = pendingAddModels
+	}
+	settings.UpstreamModelUpdateLastRemovedModels = pendingRemoveModels
+
+	if err = updateChannelUpstreamModelSettings(channel, *settings, modelsChanged); err != nil {
+		return false, autoAdded, err
+	}
+	if modelsChanged {
+		if err = channel.UpdateAbilities(nil); err != nil {
+			return true, autoAdded, err
+		}
+	}
+	return modelsChanged, autoAdded, nil
+}
+
+func refreshChannelRuntimeCache() {
+	if common.MemoryCacheEnabled {
+		func() {
+			defer func() {
+				if r := recover(); r != nil {
+					common.SysLog(fmt.Sprintf("InitChannelCache panic: %v", r))
+				}
+			}()
+			model.InitChannelCache()
+		}()
+	}
+	service.ResetProxyClientCache()
+}
+
+func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, failedChannels int) bool {
+	if changedChannels <= 0 && failedChannels <= 0 {
+		return true
+	}
+
+	channelUpstreamModelUpdateNotifyState.Lock()
+	defer channelUpstreamModelUpdateNotifyState.Unlock()
+
+	if channelUpstreamModelUpdateNotifyState.lastNotifiedAt > 0 &&
+		now-channelUpstreamModelUpdateNotifyState.lastNotifiedAt < channelUpstreamModelUpdateNotifySuppressWindowSeconds &&
+		channelUpstreamModelUpdateNotifyState.lastChangedChannels == changedChannels &&
+		channelUpstreamModelUpdateNotifyState.lastFailedChannels == failedChannels {
+		return false
+	}
+
+	channelUpstreamModelUpdateNotifyState.lastNotifiedAt = now
+	channelUpstreamModelUpdateNotifyState.lastChangedChannels = changedChannels
+	channelUpstreamModelUpdateNotifyState.lastFailedChannels = failedChannels
+	return true
+}
+
+func buildUpstreamModelUpdateTaskNotificationContent(
+	checkedChannels int,
+	changedChannels int,
+	detectedAddModels int,
+	detectedRemoveModels int,
+	autoAddedModels int,
+	failedChannelIDs []int,
+	channelSummaries []upstreamModelUpdateChannelSummary,
+	addModelSamples []string,
+	removeModelSamples []string,
+) string {
+	var builder strings.Builder
+	failedChannels := len(failedChannelIDs)
+	builder.WriteString(fmt.Sprintf(
+		"上游模型巡检摘要:检测渠道 %d 个,发现变更 %d 个,新增 %d 个,删除 %d 个,自动同步新增 %d 个,失败 %d 个。",
+		checkedChannels,
+		changedChannels,
+		detectedAddModels,
+		detectedRemoveModels,
+		autoAddedModels,
+		failedChannels,
+	))
+
+	if len(channelSummaries) > 0 {
+		displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
+		builder.WriteString(fmt.Sprintf("\n\n变更渠道明细(展示 %d/%d):", displayCount, len(channelSummaries)))
+		for _, summary := range channelSummaries[:displayCount] {
+			builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount))
+		}
+		if len(channelSummaries) > displayCount {
+			builder.WriteString(fmt.Sprintf("\n- 其余 %d 个渠道已省略", len(channelSummaries)-displayCount))
+		}
+	}
+
+	normalizedAddModelSamples := normalizeModelNames(addModelSamples)
+	if len(normalizedAddModelSamples) > 0 {
+		displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
+		builder.WriteString(fmt.Sprintf("\n\n新增模型示例(展示 %d/%d):%s",
+			displayCount,
+			len(normalizedAddModelSamples),
+			strings.Join(normalizedAddModelSamples[:displayCount], ", "),
+		))
+		if len(normalizedAddModelSamples) > displayCount {
+			builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedAddModelSamples)-displayCount))
+		}
+	}
+
+	normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
+	if len(normalizedRemoveModelSamples) > 0 {
+		displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
+		builder.WriteString(fmt.Sprintf("\n\n删除模型示例(展示 %d/%d):%s",
+			displayCount,
+			len(normalizedRemoveModelSamples),
+			strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
+		))
+		if len(normalizedRemoveModelSamples) > displayCount {
+			builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedRemoveModelSamples)-displayCount))
+		}
+	}
+
+	if failedChannels > 0 {
+		displayCount := min(failedChannels, channelUpstreamModelUpdateNotifyMaxFailedChannelIDs)
+		displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string {
+			return fmt.Sprintf("%d", channelID)
+		})
+		builder.WriteString(fmt.Sprintf(
+			"\n\n失败渠道 ID(展示 %d/%d):%s",
+			displayCount,
+			failedChannels,
+			strings.Join(displayIDs, ", "),
+		))
+		if failedChannels > displayCount {
+			builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", failedChannels-displayCount))
+		}
+	}
+	return builder.String()
+}
+
+func runChannelUpstreamModelUpdateTaskOnce() {
+	if !channelUpstreamModelUpdateTaskRunning.CompareAndSwap(false, true) {
+		return
+	}
+	defer channelUpstreamModelUpdateTaskRunning.Store(false)
+
+	checkedChannels := 0
+	failedChannels := 0
+	failedChannelIDs := make([]int, 0)
+	changedChannels := 0
+	detectedAddModels := 0
+	detectedRemoveModels := 0
+	autoAddedModels := 0
+	channelSummaries := make([]upstreamModelUpdateChannelSummary, 0)
+	addModelSamples := make([]string, 0)
+	removeModelSamples := make([]string, 0)
+	refreshNeeded := false
+
+	lastID := 0
+	for {
+		var channels []*model.Channel
+		query := model.DB.
+			Select("id", "name", "type", "key", "status", "base_url", "models", "settings", "setting", "other", "group", "priority", "weight", "tag", "channel_info", "header_override").
+			Where("status = ?", common.ChannelStatusEnabled).
+			Order("id asc").
+			Limit(channelUpstreamModelUpdateTaskBatchSize)
+		if lastID > 0 {
+			query = query.Where("id > ?", lastID)
+		}
+		err := query.Find(&channels).Error
+		if err != nil {
+			common.SysLog(fmt.Sprintf("upstream model update task query failed: %v", err))
+			break
+		}
+		if len(channels) == 0 {
+			break
+		}
+		lastID = channels[len(channels)-1].Id
+
+		for _, channel := range channels {
+			if channel == nil {
+				continue
+			}
+
+			settings := channel.GetOtherSettings()
+			if !settings.UpstreamModelUpdateCheckEnabled {
+				continue
+			}
+
+			checkedChannels++
+			modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, false, true)
+			if err != nil {
+				failedChannels++
+				failedChannelIDs = append(failedChannelIDs, channel.Id)
+				common.SysLog(fmt.Sprintf("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
+				continue
+			}
+			currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
+			currentRemoveModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
+			currentAddCount := len(currentAddModels) + autoAdded
+			currentRemoveCount := len(currentRemoveModels)
+			detectedAddModels += currentAddCount
+			detectedRemoveModels += currentRemoveCount
+			if currentAddCount > 0 || currentRemoveCount > 0 {
+				changedChannels++
+				channelSummaries = append(channelSummaries, upstreamModelUpdateChannelSummary{
+					ChannelName: channel.Name,
+					AddCount:    currentAddCount,
+					RemoveCount: currentRemoveCount,
+				})
+			}
+			addModelSamples = mergeModelNames(addModelSamples, currentAddModels)
+			removeModelSamples = mergeModelNames(removeModelSamples, currentRemoveModels)
+			if modelsChanged {
+				refreshNeeded = true
+			}
+			autoAddedModels += autoAdded
+
+			if common.RequestInterval > 0 {
+				time.Sleep(common.RequestInterval)
+			}
+		}
+
+		if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
+			break
+		}
+	}
+
+	if refreshNeeded {
+		refreshChannelRuntimeCache()
+	}
+
+	if checkedChannels > 0 || common.DebugEnabled {
+		common.SysLog(fmt.Sprintf(
+			"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
+			checkedChannels,
+			changedChannels,
+			detectedAddModels,
+			detectedRemoveModels,
+			failedChannels,
+			autoAddedModels,
+		))
+	}
+	if changedChannels > 0 || failedChannels > 0 {
+		now := common.GetTimestamp()
+		if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) {
+			common.SysLog(fmt.Sprintf(
+				"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
+				changedChannels,
+				failedChannels,
+			))
+			return
+		}
+		service.NotifyUpstreamModelUpdateWatchers(
+			"上游模型巡检通知",
+			buildUpstreamModelUpdateTaskNotificationContent(
+				checkedChannels,
+				changedChannels,
+				detectedAddModels,
+				detectedRemoveModels,
+				autoAddedModels,
+				failedChannelIDs,
+				channelSummaries,
+				addModelSamples,
+				removeModelSamples,
+			),
+		)
+	}
+}
+
+func StartChannelUpstreamModelUpdateTask() {
+	channelUpstreamModelUpdateTaskOnce.Do(func() {
+		if !common.IsMasterNode {
+			return
+		}
+		if !common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true) {
+			common.SysLog("upstream model update task disabled by CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED")
+			return
+		}
+
+		intervalMinutes := common.GetEnvOrDefault(
+			"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
+			channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
+		)
+		if intervalMinutes < 1 {
+			intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
+		}
+		interval := time.Duration(intervalMinutes) * time.Minute
+
+		go func() {
+			common.SysLog(fmt.Sprintf("upstream model update task started: interval=%s", interval))
+			runChannelUpstreamModelUpdateTaskOnce()
+			ticker := time.NewTicker(interval)
+			defer ticker.Stop()
+			for range ticker.C {
+				runChannelUpstreamModelUpdateTaskOnce()
+			}
+		}()
+	})
+}
+
+func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
+	var req applyChannelUpstreamModelUpdatesRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if req.ID <= 0 {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "invalid channel id",
+		})
+		return
+	}
+
+	channel, err := model.GetChannelById(req.ID, true)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	beforeSettings := channel.GetOtherSettings()
+	ignoredModels := intersectModelNames(req.IgnoreModels, beforeSettings.UpstreamModelUpdateLastDetectedModels)
+
+	addedModels, removedModels, remainingModels, remainingRemoveModels, modelsChanged, err := applyChannelUpstreamModelUpdates(
+		channel,
+		req.AddModels,
+		req.IgnoreModels,
+		req.RemoveModels,
+	)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	if modelsChanged {
+		refreshChannelRuntimeCache()
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"id":                      channel.Id,
+			"added_models":            addedModels,
+			"removed_models":          removedModels,
+			"ignored_models":          ignoredModels,
+			"remaining_models":        remainingModels,
+			"remaining_remove_models": remainingRemoveModels,
+			"models":                  channel.Models,
+			"settings":                channel.OtherSettings,
+		},
+	})
+}
+
+func DetectChannelUpstreamModelUpdates(c *gin.Context) {
+	var req applyChannelUpstreamModelUpdatesRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if req.ID <= 0 {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "invalid channel id",
+		})
+		return
+	}
+
+	channel, err := model.GetChannelById(req.ID, true)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	settings := channel.GetOtherSettings()
+	if !settings.UpstreamModelUpdateCheckEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该渠道未开启上游模型更新检测",
+		})
+		return
+	}
+
+	modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if modelsChanged {
+		refreshChannelRuntimeCache()
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": detectChannelUpstreamModelUpdatesResult{
+			ChannelID:       channel.Id,
+			ChannelName:     channel.Name,
+			AddModels:       normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels),
+			RemoveModels:    normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels),
+			LastCheckTime:   settings.UpstreamModelUpdateLastCheckTime,
+			AutoAddedModels: autoAdded,
+		},
+	})
+}
+
+func applyChannelUpstreamModelUpdates(
+	channel *model.Channel,
+	addModelsInput []string,
+	ignoreModelsInput []string,
+	removeModelsInput []string,
+) (
+	addedModels []string,
+	removedModels []string,
+	remainingModels []string,
+	remainingRemoveModels []string,
+	modelsChanged bool,
+	err error,
+) {
+	settings := channel.GetOtherSettings()
+	pendingAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
+	pendingRemoveModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
+	addModels := intersectModelNames(addModelsInput, pendingAddModels)
+	ignoreModels := intersectModelNames(ignoreModelsInput, pendingAddModels)
+	removeModels := intersectModelNames(removeModelsInput, pendingRemoveModels)
+	removeModels = subtractModelNames(removeModels, addModels)
+
+	originModels := normalizeModelNames(channel.GetModels())
+	nextModels := applySelectedModelChanges(originModels, addModels, removeModels)
+	modelsChanged = !slices.Equal(originModels, nextModels)
+	if modelsChanged {
+		channel.Models = strings.Join(nextModels, ",")
+	}
+
+	settings.UpstreamModelUpdateIgnoredModels = mergeModelNames(settings.UpstreamModelUpdateIgnoredModels, ignoreModels)
+	if len(addModels) > 0 {
+		settings.UpstreamModelUpdateIgnoredModels = subtractModelNames(settings.UpstreamModelUpdateIgnoredModels, addModels)
+	}
+	remainingModels = subtractModelNames(pendingAddModels, append(addModels, ignoreModels...))
+	remainingRemoveModels = subtractModelNames(pendingRemoveModels, removeModels)
+	settings.UpstreamModelUpdateLastDetectedModels = remainingModels
+	settings.UpstreamModelUpdateLastRemovedModels = remainingRemoveModels
+	settings.UpstreamModelUpdateLastCheckTime = common.GetTimestamp()
+
+	if err := updateChannelUpstreamModelSettings(channel, settings, modelsChanged); err != nil {
+		return nil, nil, nil, nil, false, err
+	}
+
+	if modelsChanged {
+		if err := channel.UpdateAbilities(nil); err != nil {
+			return addModels, removeModels, remainingModels, remainingRemoveModels, true, err
+		}
+	}
+	return addModels, removeModels, remainingModels, remainingRemoveModels, modelsChanged, nil
+}
+
+func collectPendingApplyUpstreamModelChanges(settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string) {
+	return normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels), normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
+}
+
+func findEnabledChannelsAfterID(lastID int, batchSize int) ([]*model.Channel, error) {
+	var channels []*model.Channel
+	query := model.DB.
+		Select("id", "name", "type", "key", "status", "base_url", "models", "settings", "setting", "other", "group", "priority", "weight", "tag", "channel_info", "header_override").
+		Where("status = ?", common.ChannelStatusEnabled).
+		Order("id asc").
+		Limit(batchSize)
+	if lastID > 0 {
+		query = query.Where("id > ?", lastID)
+	}
+	return channels, query.Find(&channels).Error
+}
+
+func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
+	results := make([]applyAllChannelUpstreamModelUpdatesResult, 0)
+	failed := make([]int, 0)
+	refreshNeeded := false
+	addedModelCount := 0
+	removedModelCount := 0
+
+	lastID := 0
+	for {
+		channels, err := findEnabledChannelsAfterID(lastID, channelUpstreamModelUpdateTaskBatchSize)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		if len(channels) == 0 {
+			break
+		}
+		lastID = channels[len(channels)-1].Id
+
+		for _, channel := range channels {
+			if channel == nil {
+				continue
+			}
+
+			settings := channel.GetOtherSettings()
+			if !settings.UpstreamModelUpdateCheckEnabled {
+				continue
+			}
+
+			pendingAddModels, pendingRemoveModels := collectPendingApplyUpstreamModelChanges(settings)
+			if len(pendingAddModels) == 0 && len(pendingRemoveModels) == 0 {
+				continue
+			}
+
+			addedModels, removedModels, remainingModels, remainingRemoveModels, modelsChanged, err := applyChannelUpstreamModelUpdates(
+				channel,
+				pendingAddModels,
+				nil,
+				pendingRemoveModels,
+			)
+			if err != nil {
+				failed = append(failed, channel.Id)
+				continue
+			}
+			if modelsChanged {
+				refreshNeeded = true
+			}
+			addedModelCount += len(addedModels)
+			removedModelCount += len(removedModels)
+			results = append(results, applyAllChannelUpstreamModelUpdatesResult{
+				ChannelID:             channel.Id,
+				ChannelName:           channel.Name,
+				AddedModels:           addedModels,
+				RemovedModels:         removedModels,
+				RemainingModels:       remainingModels,
+				RemainingRemoveModels: remainingRemoveModels,
+			})
+		}
+
+		if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
+			break
+		}
+	}
+
+	if refreshNeeded {
+		refreshChannelRuntimeCache()
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"processed_channels": len(results),
+			"added_models":       addedModelCount,
+			"removed_models":     removedModelCount,
+			"failed_channel_ids": failed,
+			"results":            results,
+		},
+	})
+}
+
+func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
+	results := make([]detectChannelUpstreamModelUpdatesResult, 0)
+	failed := make([]int, 0)
+	detectedAddCount := 0
+	detectedRemoveCount := 0
+	refreshNeeded := false
+
+	lastID := 0
+	for {
+		channels, err := findEnabledChannelsAfterID(lastID, channelUpstreamModelUpdateTaskBatchSize)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		if len(channels) == 0 {
+			break
+		}
+		lastID = channels[len(channels)-1].Id
+
+		for _, channel := range channels {
+			if channel == nil {
+				continue
+			}
+			settings := channel.GetOtherSettings()
+			if !settings.UpstreamModelUpdateCheckEnabled {
+				continue
+			}
+
+			modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
+			if err != nil {
+				failed = append(failed, channel.Id)
+				continue
+			}
+			if modelsChanged {
+				refreshNeeded = true
+			}
+
+			addModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
+			removeModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
+			detectedAddCount += len(addModels)
+			detectedRemoveCount += len(removeModels)
+			results = append(results, detectChannelUpstreamModelUpdatesResult{
+				ChannelID:       channel.Id,
+				ChannelName:     channel.Name,
+				AddModels:       addModels,
+				RemoveModels:    removeModels,
+				LastCheckTime:   settings.UpstreamModelUpdateLastCheckTime,
+				AutoAddedModels: autoAdded,
+			})
+		}
+
+		if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
+			break
+		}
+	}
+
+	if refreshNeeded {
+		refreshChannelRuntimeCache()
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"processed_channels":       len(results),
+			"failed_channel_ids":       failed,
+			"detected_add_models":      detectedAddCount,
+			"detected_remove_models":   detectedRemoveCount,
+			"channel_detected_results": results,
+		},
+	})
+}

+ 167 - 0
controller/channel_upstream_update_test.go

@@ -0,0 +1,167 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNormalizeModelNames(t *testing.T) {
+	result := normalizeModelNames([]string{
+		" gpt-4o ",
+		"",
+		"gpt-4o",
+		"gpt-4.1",
+		"   ",
+	})
+
+	require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
+}
+
+func TestMergeModelNames(t *testing.T) {
+	result := mergeModelNames(
+		[]string{"gpt-4o", "gpt-4.1"},
+		[]string{"gpt-4.1", " gpt-4.1-mini ", "gpt-4o"},
+	)
+
+	require.Equal(t, []string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"}, result)
+}
+
+func TestSubtractModelNames(t *testing.T) {
+	result := subtractModelNames(
+		[]string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"},
+		[]string{"gpt-4.1", "not-exists"},
+	)
+
+	require.Equal(t, []string{"gpt-4o", "gpt-4.1-mini"}, result)
+}
+
+func TestIntersectModelNames(t *testing.T) {
+	result := intersectModelNames(
+		[]string{"gpt-4o", "gpt-4.1", "gpt-4.1", "not-exists"},
+		[]string{"gpt-4.1", "gpt-4o-mini", "gpt-4o"},
+	)
+
+	require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
+}
+
+func TestApplySelectedModelChanges(t *testing.T) {
+	t.Run("add and remove together", func(t *testing.T) {
+		result := applySelectedModelChanges(
+			[]string{"gpt-4o", "gpt-4.1", "claude-3"},
+			[]string{"gpt-4.1-mini"},
+			[]string{"claude-3"},
+		)
+
+		require.Equal(t, []string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"}, result)
+	})
+
+	t.Run("add wins when conflict with remove", func(t *testing.T) {
+		result := applySelectedModelChanges(
+			[]string{"gpt-4o"},
+			[]string{"gpt-4.1"},
+			[]string{"gpt-4.1"},
+		)
+
+		require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
+	})
+}
+
+func TestCollectPendingApplyUpstreamModelChanges(t *testing.T) {
+	settings := dto.ChannelOtherSettings{
+		UpstreamModelUpdateLastDetectedModels: []string{" gpt-4o ", "gpt-4o", "gpt-4.1"},
+		UpstreamModelUpdateLastRemovedModels:  []string{" old-model ", "", "old-model"},
+	}
+
+	pendingAddModels, pendingRemoveModels := collectPendingApplyUpstreamModelChanges(settings)
+
+	require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, pendingAddModels)
+	require.Equal(t, []string{"old-model"}, pendingRemoveModels)
+}
+
+func TestNormalizeChannelModelMapping(t *testing.T) {
+	modelMapping := `{
+		" alias-model ": " upstream-model ",
+		"": "invalid",
+		"invalid-target": ""
+	}`
+	channel := &model.Channel{
+		ModelMapping: &modelMapping,
+	}
+
+	result := normalizeChannelModelMapping(channel)
+	require.Equal(t, map[string]string{
+		"alias-model": "upstream-model",
+	}, result)
+}
+
+func TestCollectPendingUpstreamModelChangesFromModels_WithModelMapping(t *testing.T) {
+	pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels(
+		[]string{"alias-model", "gpt-4o", "stale-model"},
+		[]string{"gpt-4o", "gpt-4.1", "mapped-target"},
+		[]string{"gpt-4.1"},
+		map[string]string{
+			"alias-model": "mapped-target",
+		},
+	)
+
+	require.Equal(t, []string{}, pendingAddModels)
+	require.Equal(t, []string{"stale-model"}, pendingRemoveModels)
+}
+
+func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) {
+	channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12)
+	for i := 0; i < 12; i++ {
+		channelSummaries = append(channelSummaries, upstreamModelUpdateChannelSummary{
+			ChannelName: "channel-" + string(rune('A'+i)),
+			AddCount:    i + 1,
+			RemoveCount: i,
+		})
+	}
+
+	content := buildUpstreamModelUpdateTaskNotificationContent(
+		24,
+		12,
+		56,
+		21,
+		9,
+		[]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
+		channelSummaries,
+		[]string{
+			"gpt-4.1", "gpt-4.1-mini", "o3", "o4-mini", "gemini-2.5-pro", "claude-3.7-sonnet",
+			"qwen-max", "deepseek-r1", "llama-3.3-70b", "mistral-large", "command-r-plus", "doubao-pro-32k",
+			"hunyuan-large",
+		},
+		[]string{
+			"gpt-3.5-turbo", "claude-2.1", "gemini-1.5-pro", "mixtral-8x7b", "qwen-plus", "glm-4",
+			"yi-large", "moonshot-v1", "doubao-lite",
+		},
+	)
+
+	require.Contains(t, content, "其余 4 个渠道已省略")
+	require.Contains(t, content, "其余 1 个已省略")
+	require.Contains(t, content, "失败渠道 ID(展示 10/12)")
+	require.Contains(t, content, "其余 2 个已省略")
+}
+
+func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
+	channelUpstreamModelUpdateNotifyState.Lock()
+	channelUpstreamModelUpdateNotifyState.lastNotifiedAt = 0
+	channelUpstreamModelUpdateNotifyState.lastChangedChannels = 0
+	channelUpstreamModelUpdateNotifyState.lastFailedChannels = 0
+	channelUpstreamModelUpdateNotifyState.Unlock()
+
+	baseTime := int64(2000000)
+
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime, 6, 0))
+	require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+3600, 6, 0))
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+3600, 7, 0))
+	require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+7200, 7, 0))
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+8000, 0, 3))
+	require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+9000, 0, 3))
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+10000, 0, 4))
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0))
+	require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90001, 0, 0))
+}

+ 22 - 15
controller/user.go

@@ -1032,17 +1032,18 @@ func TopUp(c *gin.Context) {
 }
 
 type UpdateUserSettingRequest struct {
-	QuotaWarningType           string  `json:"notify_type"`
-	QuotaWarningThreshold      float64 `json:"quota_warning_threshold"`
-	WebhookUrl                 string  `json:"webhook_url,omitempty"`
-	WebhookSecret              string  `json:"webhook_secret,omitempty"`
-	NotificationEmail          string  `json:"notification_email,omitempty"`
-	BarkUrl                    string  `json:"bark_url,omitempty"`
-	GotifyUrl                  string  `json:"gotify_url,omitempty"`
-	GotifyToken                string  `json:"gotify_token,omitempty"`
-	GotifyPriority             int     `json:"gotify_priority,omitempty"`
-	AcceptUnsetModelRatioModel bool    `json:"accept_unset_model_ratio_model"`
-	RecordIpLog                bool    `json:"record_ip_log"`
+	QuotaWarningType                 string  `json:"notify_type"`
+	QuotaWarningThreshold            float64 `json:"quota_warning_threshold"`
+	WebhookUrl                       string  `json:"webhook_url,omitempty"`
+	WebhookSecret                    string  `json:"webhook_secret,omitempty"`
+	NotificationEmail                string  `json:"notification_email,omitempty"`
+	BarkUrl                          string  `json:"bark_url,omitempty"`
+	GotifyUrl                        string  `json:"gotify_url,omitempty"`
+	GotifyToken                      string  `json:"gotify_token,omitempty"`
+	GotifyPriority                   int     `json:"gotify_priority,omitempty"`
+	UpstreamModelUpdateNotifyEnabled *bool   `json:"upstream_model_update_notify_enabled,omitempty"`
+	AcceptUnsetModelRatioModel       bool    `json:"accept_unset_model_ratio_model"`
+	RecordIpLog                      bool    `json:"record_ip_log"`
 }
 
 func UpdateUserSetting(c *gin.Context) {
@@ -1132,13 +1133,19 @@ func UpdateUserSetting(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
+	existingSettings := user.GetSetting()
+	upstreamModelUpdateNotifyEnabled := existingSettings.UpstreamModelUpdateNotifyEnabled
+	if user.Role >= common.RoleAdminUser && req.UpstreamModelUpdateNotifyEnabled != nil {
+		upstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
+	}
 
 	// 构建设置
 	settings := dto.UserSetting{
-		NotifyType:            req.QuotaWarningType,
-		QuotaWarningThreshold: req.QuotaWarningThreshold,
-		AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
-		RecordIpLog:           req.RecordIpLog,
+		NotifyType:                       req.QuotaWarningType,
+		QuotaWarningThreshold:            req.QuotaWarningThreshold,
+		UpstreamModelUpdateNotifyEnabled: upstreamModelUpdateNotifyEnabled,
+		AcceptUnsetRatioModel:            req.AcceptUnsetModelRatioModel,
+		RecordIpLog:                      req.RecordIpLog,
 	}
 
 	// 如果是webhook类型,添加webhook相关设置

+ 16 - 10
dto/channel_settings.go

@@ -24,16 +24,22 @@ const (
 )
 
 type ChannelOtherSettings struct {
-	AzureResponsesVersion   string        `json:"azure_responses_version,omitempty"`
-	VertexKeyType           VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
-	OpenRouterEnterprise    *bool         `json:"openrouter_enterprise,omitempty"`
-	ClaudeBetaQuery         bool          `json:"claude_beta_query,omitempty"`         // Claude 渠道是否强制追加 ?beta=true
-	AllowServiceTier        bool          `json:"allow_service_tier,omitempty"`        // 是否允许 service_tier 透传(默认过滤以避免额外计费)
-	AllowInferenceGeo       bool          `json:"allow_inference_geo,omitempty"`       // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规)
-	DisableStore            bool          `json:"disable_store,omitempty"`             // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
-	AllowSafetyIdentifier   bool          `json:"allow_safety_identifier,omitempty"`   // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
-	AllowIncludeObfuscation bool          `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
-	AwsKeyType              AwsKeyType    `json:"aws_key_type,omitempty"`
+	AzureResponsesVersion                 string        `json:"azure_responses_version,omitempty"`
+	VertexKeyType                         VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
+	OpenRouterEnterprise                  *bool         `json:"openrouter_enterprise,omitempty"`
+	ClaudeBetaQuery                       bool          `json:"claude_beta_query,omitempty"`          // Claude 渠道是否强制追加 ?beta=true
+	AllowServiceTier                      bool          `json:"allow_service_tier,omitempty"`         // 是否允许 service_tier 透传(默认过滤以避免额外计费)
+	AllowInferenceGeo                     bool          `json:"allow_inference_geo,omitempty"`        // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
+	AllowSafetyIdentifier                 bool          `json:"allow_safety_identifier,omitempty"`    // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
+	DisableStore                          bool          `json:"disable_store,omitempty"`              // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
+	AllowIncludeObfuscation               bool          `json:"allow_include_obfuscation,	omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
+	AwsKeyType                            AwsKeyType    `json:"aws_key_type,omitempty"`
+	UpstreamModelUpdateCheckEnabled       bool          `json:"upstream_model_update_check_enabled,omitempty"`        // 是否检测上游模型更新
+	UpstreamModelUpdateAutoSyncEnabled    bool          `json:"upstream_model_update_auto_sync_enabled,omitempty"`    // 是否自动同步上游模型更新
+	UpstreamModelUpdateLastCheckTime      int64         `json:"upstream_model_update_last_check_time,omitempty"`      // 上次检测时间
+	UpstreamModelUpdateLastDetectedModels []string      `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
+	UpstreamModelUpdateLastRemovedModels  []string      `json:"upstream_model_update_last_removed_models,omitempty"`  // 上次检测到的可删除模型
+	UpstreamModelUpdateIgnoredModels      []string      `json:"upstream_model_update_ignored_models,omitempty"`       // 手动忽略的模型
 }
 
 func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {

+ 15 - 14
dto/user_settings.go

@@ -1,20 +1,21 @@
 package dto
 
 type UserSetting struct {
-	NotifyType            string  `json:"notify_type,omitempty"`                    // QuotaWarningType 额度预警类型
-	QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"`        // QuotaWarningThreshold 额度预警阈值
-	WebhookUrl            string  `json:"webhook_url,omitempty"`                    // WebhookUrl webhook地址
-	WebhookSecret         string  `json:"webhook_secret,omitempty"`                 // WebhookSecret webhook密钥
-	NotificationEmail     string  `json:"notification_email,omitempty"`             // NotificationEmail 通知邮箱地址
-	BarkUrl               string  `json:"bark_url,omitempty"`                       // BarkUrl Bark推送URL
-	GotifyUrl             string  `json:"gotify_url,omitempty"`                     // GotifyUrl Gotify服务器地址
-	GotifyToken           string  `json:"gotify_token,omitempty"`                   // GotifyToken Gotify应用令牌
-	GotifyPriority        int     `json:"gotify_priority"`                          // GotifyPriority Gotify消息优先级
-	AcceptUnsetRatioModel bool    `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
-	RecordIpLog           bool    `json:"record_ip_log,omitempty"`                  // 是否记录请求和错误日志IP
-	SidebarModules        string  `json:"sidebar_modules,omitempty"`                // SidebarModules 左侧边栏模块配置
-	BillingPreference     string  `json:"billing_preference,omitempty"`             // BillingPreference 扣费策略(订阅/钱包)
-	Language              string  `json:"language,omitempty"`                       // Language 用户语言偏好 (zh, en)
+	NotifyType                       string  `json:"notify_type,omitempty"`                          // QuotaWarningType 额度预警类型
+	QuotaWarningThreshold            float64 `json:"quota_warning_threshold,omitempty"`              // QuotaWarningThreshold 额度预警阈值
+	WebhookUrl                       string  `json:"webhook_url,omitempty"`                          // WebhookUrl webhook地址
+	WebhookSecret                    string  `json:"webhook_secret,omitempty"`                       // WebhookSecret webhook密钥
+	NotificationEmail                string  `json:"notification_email,omitempty"`                   // NotificationEmail 通知邮箱地址
+	BarkUrl                          string  `json:"bark_url,omitempty"`                             // BarkUrl Bark推送URL
+	GotifyUrl                        string  `json:"gotify_url,omitempty"`                           // GotifyUrl Gotify服务器地址
+	GotifyToken                      string  `json:"gotify_token,omitempty"`                         // GotifyToken Gotify应用令牌
+	GotifyPriority                   int     `json:"gotify_priority"`                                // GotifyPriority Gotify消息优先级
+	UpstreamModelUpdateNotifyEnabled bool    `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
+	AcceptUnsetRatioModel            bool    `json:"accept_unset_model_ratio_model,omitempty"`       // AcceptUnsetRatioModel 是否接受未设置价格的模型
+	RecordIpLog                      bool    `json:"record_ip_log,omitempty"`                        // 是否记录请求和错误日志IP
+	SidebarModules                   string  `json:"sidebar_modules,omitempty"`                      // SidebarModules 左侧边栏模块配置
+	BillingPreference                string  `json:"billing_preference,omitempty"`                   // BillingPreference 扣费策略(订阅/钱包)
+	Language                         string  `json:"language,omitempty"`                             // Language 用户语言偏好 (zh, en)
 }
 
 var (

+ 3 - 0
main.go

@@ -121,6 +121,9 @@ func main() {
 		return a
 	}
 
+	// Channel upstream model update check task
+	controller.StartChannelUpstreamModelUpdateTask()
+
 	if common.IsMasterNode && constant.UpdateTask {
 		gopool.Go(func() {
 			controller.UpdateMidjourneyTaskBulk()

+ 4 - 0
router/api-router.go

@@ -237,6 +237,10 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.GET("/tag/models", controller.GetTagModels)
 			channelRoute.POST("/copy/:id", controller.CopyChannel)
 			channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
+			channelRoute.POST("/upstream_updates/apply", controller.ApplyChannelUpstreamModelUpdates)
+			channelRoute.POST("/upstream_updates/apply_all", controller.ApplyAllChannelUpstreamModelUpdates)
+			channelRoute.POST("/upstream_updates/detect", controller.DetectChannelUpstreamModelUpdates)
+			channelRoute.POST("/upstream_updates/detect_all", controller.DetectAllChannelUpstreamModelUpdates)
 		}
 		tokenRoute := apiRouter.Group("/token")
 		tokenRoute.Use(middleware.UserAuth())

+ 7 - 5
service/task_billing_test.go

@@ -125,8 +125,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc
 			SubscriptionId: subscriptionId,
 			TokenId:        tokenId,
 			BillingContext: &model.TaskBillingContext{
-				ModelPrice: 0.02,
-				GroupRatio: 1.0,
+				ModelPrice:      0.02,
+				GroupRatio:      1.0,
 				OriginModelName: "test-model",
 			},
 		},
@@ -615,9 +615,11 @@ type mockAdaptor struct {
 	adjustReturn int
 }
 
-func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo)                                            {}
-func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error)  { return nil, nil }
-func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error)                     { return nil, nil }
+func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {}
+func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
+	return nil, nil
+}
+func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil }
 func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
 	return m.adjustReturn
 }

+ 26 - 0
service/user_notify.go

@@ -22,6 +22,32 @@ func NotifyRootUser(t string, subject string, content string) {
 	}
 }
 
+func NotifyUpstreamModelUpdateWatchers(subject string, content string) {
+	var users []model.User
+	if err := model.DB.
+		Select("id", "email", "role", "status", "setting").
+		Where("status = ? AND role >= ?", common.UserStatusEnabled, common.RoleAdminUser).
+		Find(&users).Error; err != nil {
+		common.SysLog(fmt.Sprintf("failed to query upstream update notification users: %s", err.Error()))
+		return
+	}
+
+	notification := dto.NewNotify(dto.NotifyTypeChannelUpdate, subject, content, nil)
+	sentCount := 0
+	for _, user := range users {
+		userSetting := user.GetSetting()
+		if !userSetting.UpstreamModelUpdateNotifyEnabled {
+			continue
+		}
+		if err := NotifyUser(user.Id, user.Email, userSetting, notification); err != nil {
+			common.SysLog(fmt.Sprintf("failed to notify user %d for upstream model update: %s", user.Id, err.Error()))
+			continue
+		}
+		sentCount++
+	}
+	common.SysLog(fmt.Sprintf("upstream model update notifications sent: %d", sentCount))
+}
+
 func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data dto.Notify) error {
 	notifyType := userSetting.NotifyType
 	if notifyType == "" {

+ 5 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -86,6 +86,7 @@ const PersonalSetting = () => {
     gotifyUrl: '',
     gotifyToken: '',
     gotifyPriority: 5,
+    upstreamModelUpdateNotifyEnabled: false,
     acceptUnsetModelRatioModel: false,
     recordIpLog: false,
   });
@@ -158,6 +159,8 @@ const PersonalSetting = () => {
         gotifyToken: settings.gotify_token || '',
         gotifyPriority:
           settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
+        upstreamModelUpdateNotifyEnabled:
+          settings.upstream_model_update_notify_enabled === true,
         acceptUnsetModelRatioModel:
           settings.accept_unset_model_ratio_model || false,
         recordIpLog: settings.record_ip_log || false,
@@ -426,6 +429,8 @@ const PersonalSetting = () => {
           const parsed = parseInt(notificationSettings.gotifyPriority);
           return isNaN(parsed) ? 5 : parsed;
         })(),
+        upstream_model_update_notify_enabled:
+          notificationSettings.upstreamModelUpdateNotifyEnabled === true,
         accept_unset_model_ratio_model:
           notificationSettings.acceptUnsetModelRatioModel,
         record_ip_log: notificationSettings.recordIpLog,

+ 16 - 0
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -58,6 +58,7 @@ const NotificationSettings = ({
   const formApiRef = useRef(null);
   const [statusState] = useContext(StatusContext);
   const [userState] = useContext(UserContext);
+  const isAdminOrRoot = (userState?.user?.role || 0) >= 10;
 
   // 左侧边栏设置相关状态
   const [sidebarLoading, setSidebarLoading] = useState(false);
@@ -470,6 +471,21 @@ const NotificationSettings = ({
                   ]}
                 />
 
+                {isAdminOrRoot && (
+                  <Form.Switch
+                    field='upstreamModelUpdateNotifyEnabled'
+                    label={t('接收上游模型更新通知')}
+                    checkedText={t('开')}
+                    uncheckedText={t('关')}
+                    onChange={(value) =>
+                      handleFormChange('upstreamModelUpdateNotifyEnabled', value)
+                    }
+                    extraText={t(
+                      '仅管理员可用。开启后,当系统定时检测全部渠道发现上游模型变更或检测异常时,将按你选择的通知方式发送汇总通知;渠道或模型过多时会自动省略部分明细。',
+                    )}
+                  />
+                )}
+
                 {/* 邮件通知设置 */}
                 {notificationSettings.warningType === 'email' && (
                   <Form.Input

+ 46 - 0
web/src/components/table/channels/ChannelsActions.jsx

@@ -36,6 +36,10 @@ const ChannelsActions = ({
   fixChannelsAbilities,
   updateAllChannelsBalance,
   deleteAllDisabledChannels,
+  applyAllUpstreamUpdates,
+  detectAllUpstreamUpdates,
+  detectAllUpstreamUpdatesLoading,
+  applyAllUpstreamUpdatesLoading,
   compactMode,
   setCompactMode,
   idSort,
@@ -96,6 +100,8 @@ const ChannelsActions = ({
                     size='small'
                     type='tertiary'
                     className='w-full'
+                    loading={detectAllUpstreamUpdatesLoading}
+                    disabled={detectAllUpstreamUpdatesLoading}
                     onClick={() => {
                       Modal.confirm({
                         title: t('确定?'),
@@ -146,6 +152,46 @@ const ChannelsActions = ({
                     {t('更新所有已启用通道余额')}
                   </Button>
                 </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='tertiary'
+                    className='w-full'
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t(
+                          '确定要仅检测全部渠道上游模型更新吗?(不执行新增/删除)',
+                        ),
+                        onOk: () => detectAllUpstreamUpdates(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('检测全部渠道上游更新')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='primary'
+                    className='w-full'
+                    loading={applyAllUpstreamUpdatesLoading}
+                    disabled={applyAllUpstreamUpdatesLoading}
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t('确定要对全部渠道执行上游模型更新吗?'),
+                        onOk: () => applyAllUpstreamUpdates(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('处理全部渠道上游更新')}
+                  </Button>
+                </Dropdown.Item>
                 <Dropdown.Item>
                   <Button
                     size='small'

+ 151 - 15
web/src/components/table/channels/ChannelsColumnDefs.jsx

@@ -37,8 +37,13 @@ import {
   renderQuotaWithAmount,
   showSuccess,
   showError,
+  showInfo,
 } from '../../../helpers';
-import { CHANNEL_OPTIONS } from '../../../constants';
+import {
+  CHANNEL_OPTIONS,
+  MODEL_FETCHABLE_CHANNEL_TYPES,
+} from '../../../constants';
+import { parseUpstreamUpdateMeta } from '../../../hooks/channels/upstreamUpdateUtils';
 import {
   IconTreeTriangleDown,
   IconMore,
@@ -270,6 +275,35 @@ const isRequestPassThroughEnabled = (record) => {
   }
 };
 
+const getUpstreamUpdateMeta = (record) => {
+  const supported =
+    !!record &&
+    record.children === undefined &&
+    MODEL_FETCHABLE_CHANNEL_TYPES.has(record.type);
+  if (!record || record.children !== undefined) {
+    return {
+      supported: false,
+      enabled: false,
+      pendingAddModels: [],
+      pendingRemoveModels: [],
+    };
+  }
+  const parsed =
+    record?.upstreamUpdateMeta && typeof record.upstreamUpdateMeta === 'object'
+      ? record.upstreamUpdateMeta
+      : parseUpstreamUpdateMeta(record?.settings);
+  return {
+    supported,
+    enabled: parsed?.enabled === true,
+    pendingAddModels: Array.isArray(parsed?.pendingAddModels)
+      ? parsed.pendingAddModels
+      : [],
+    pendingRemoveModels: Array.isArray(parsed?.pendingRemoveModels)
+      ? parsed.pendingRemoveModels
+      : [],
+  };
+};
+
 export const getChannelsColumns = ({
   t,
   COLUMN_KEYS,
@@ -291,6 +325,8 @@ export const getChannelsColumns = ({
   checkOllamaVersion,
   setShowMultiKeyManageModal,
   setCurrentMultiKeyChannel,
+  openUpstreamUpdateModal,
+  detectChannelUpstreamUpdates,
 }) => {
   return [
     {
@@ -304,6 +340,14 @@ export const getChannelsColumns = ({
       dataIndex: 'name',
       render: (text, record, index) => {
         const passThroughEnabled = isRequestPassThroughEnabled(record);
+        const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
+        const pendingAddCount = upstreamUpdateMeta.pendingAddModels.length;
+        const pendingRemoveCount =
+          upstreamUpdateMeta.pendingRemoveModels.length;
+        const showUpstreamUpdateTag =
+          upstreamUpdateMeta.supported &&
+          upstreamUpdateMeta.enabled &&
+          (pendingAddCount > 0 || pendingRemoveCount > 0);
         const nameNode =
           record.remark && record.remark.trim() !== '' ? (
             <Tooltip
@@ -339,26 +383,76 @@ export const getChannelsColumns = ({
             <span>{text}</span>
           );
 
-        if (!passThroughEnabled) {
+        if (!passThroughEnabled && !showUpstreamUpdateTag) {
           return nameNode;
         }
 
         return (
           <Space spacing={6} align='center'>
             {nameNode}
-            <Tooltip
-              content={t(
-                '该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
-              )}
-              trigger='hover'
-              position='topLeft'
-            >
-              <span className='inline-flex items-center'>
-                <IconAlertTriangle
-                  style={{ color: 'var(--semi-color-warning)' }}
-                />
-              </span>
-            </Tooltip>
+            {passThroughEnabled && (
+              <Tooltip
+                content={t(
+                  '该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
+                )}
+                trigger='hover'
+                position='topLeft'
+              >
+                <span className='inline-flex items-center'>
+                  <IconAlertTriangle
+                    style={{ color: 'var(--semi-color-warning)' }}
+                  />
+                </span>
+              </Tooltip>
+            )}
+            {showUpstreamUpdateTag && (
+              <Space spacing={4} align='center'>
+                {pendingAddCount > 0 ? (
+                  <Tooltip content={t('点击处理新增模型')} position='top'>
+                    <Tag
+                      color='green'
+                      type='light'
+                      size='small'
+                      shape='circle'
+                      className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        openUpstreamUpdateModal(
+                          record,
+                          upstreamUpdateMeta.pendingAddModels,
+                          upstreamUpdateMeta.pendingRemoveModels,
+                          'add',
+                        );
+                      }}
+                    >
+                      +{pendingAddCount}
+                    </Tag>
+                  </Tooltip>
+                ) : null}
+                {pendingRemoveCount > 0 ? (
+                  <Tooltip content={t('点击处理删除模型')} position='top'>
+                    <Tag
+                      color='red'
+                      type='light'
+                      size='small'
+                      shape='circle'
+                      className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        openUpstreamUpdateModal(
+                          record,
+                          upstreamUpdateMeta.pendingAddModels,
+                          upstreamUpdateMeta.pendingRemoveModels,
+                          'remove',
+                        );
+                      }}
+                    >
+                      -{pendingRemoveCount}
+                    </Tag>
+                  </Tooltip>
+                ) : null}
+              </Space>
+            )}
           </Space>
         );
       },
@@ -585,6 +679,7 @@ export const getChannelsColumns = ({
       fixed: 'right',
       render: (text, record, index) => {
         if (record.children === undefined) {
+          const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
           const moreMenuItems = [
             {
               node: 'item',
@@ -622,6 +717,47 @@ export const getChannelsColumns = ({
             },
           ];
 
+          if (upstreamUpdateMeta.supported) {
+            moreMenuItems.push({
+              node: 'item',
+              name: t('仅检测上游模型更新'),
+              type: 'tertiary',
+              onClick: () => {
+                if (!upstreamUpdateMeta.enabled) {
+                  showInfo(t('该渠道未开启上游模型更新检测'));
+                  return;
+                }
+                detectChannelUpstreamUpdates(record);
+              },
+            });
+            moreMenuItems.push({
+              node: 'item',
+              name: t('处理上游模型更新'),
+              type: 'tertiary',
+              onClick: () => {
+                if (!upstreamUpdateMeta.enabled) {
+                  showInfo(t('该渠道未开启上游模型更新检测'));
+                  return;
+                }
+                if (
+                  upstreamUpdateMeta.pendingAddModels.length === 0 &&
+                  upstreamUpdateMeta.pendingRemoveModels.length === 0
+                ) {
+                  showInfo(t('该渠道暂无可处理的上游模型更新'));
+                  return;
+                }
+                openUpstreamUpdateModal(
+                  record,
+                  upstreamUpdateMeta.pendingAddModels,
+                  upstreamUpdateMeta.pendingRemoveModels,
+                  upstreamUpdateMeta.pendingAddModels.length > 0
+                    ? 'add'
+                    : 'remove',
+                );
+              },
+            });
+          }
+
           if (record.type === 4) {
             moreMenuItems.unshift({
               node: 'item',

+ 6 - 0
web/src/components/table/channels/ChannelsTable.jsx

@@ -61,6 +61,8 @@ const ChannelsTable = (channelsData) => {
     // Multi-key management
     setShowMultiKeyManageModal,
     setCurrentMultiKeyChannel,
+    openUpstreamUpdateModal,
+    detectChannelUpstreamUpdates,
   } = channelsData;
 
   // Get all columns
@@ -86,6 +88,8 @@ const ChannelsTable = (channelsData) => {
       checkOllamaVersion,
       setShowMultiKeyManageModal,
       setCurrentMultiKeyChannel,
+      openUpstreamUpdateModal,
+      detectChannelUpstreamUpdates,
     });
   }, [
     t,
@@ -108,6 +112,8 @@ const ChannelsTable = (channelsData) => {
     checkOllamaVersion,
     setShowMultiKeyManageModal,
     setCurrentMultiKeyChannel,
+    openUpstreamUpdateModal,
+    detectChannelUpstreamUpdates,
   ]);
 
   // Filter columns based on visibility settings

+ 10 - 0
web/src/components/table/channels/index.jsx

@@ -33,6 +33,7 @@ import ColumnSelectorModal from './modals/ColumnSelectorModal';
 import EditChannelModal from './modals/EditChannelModal';
 import EditTagModal from './modals/EditTagModal';
 import MultiKeyManageModal from './modals/MultiKeyManageModal';
+import ChannelUpstreamUpdateModal from './modals/ChannelUpstreamUpdateModal';
 import { createCardProPagination } from '../../../helpers/utils';
 
 const ChannelsPage = () => {
@@ -63,6 +64,15 @@ const ChannelsPage = () => {
         channel={channelsData.currentMultiKeyChannel}
         onRefresh={channelsData.refresh}
       />
+      <ChannelUpstreamUpdateModal
+        visible={channelsData.showUpstreamUpdateModal}
+        addModels={channelsData.upstreamUpdateAddModels}
+        removeModels={channelsData.upstreamUpdateRemoveModels}
+        preferredTab={channelsData.upstreamUpdatePreferredTab}
+        confirmLoading={channelsData.upstreamApplyLoading}
+        onConfirm={channelsData.applyUpstreamUpdates}
+        onCancel={channelsData.closeUpstreamUpdateModal}
+      />
 
       {/* Main Content */}
       {channelsData.globalPassThroughEnabled ? (

+ 313 - 0
web/src/components/table/channels/modals/ChannelUpstreamUpdateModal.jsx

@@ -0,0 +1,313 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal,
+  Checkbox,
+  Empty,
+  Input,
+  Tabs,
+  Typography,
+} from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const normalizeModels = (models = []) =>
+  Array.from(
+    new Set(
+      (models || []).map((model) => String(model || '').trim()).filter(Boolean),
+    ),
+  );
+
+const filterByKeyword = (models = [], keyword = '') => {
+  const normalizedKeyword = String(keyword || '')
+    .trim()
+    .toLowerCase();
+  if (!normalizedKeyword) {
+    return models;
+  }
+  return models.filter((model) =>
+    String(model).toLowerCase().includes(normalizedKeyword),
+  );
+};
+
+const ChannelUpstreamUpdateModal = ({
+  visible,
+  addModels = [],
+  removeModels = [],
+  preferredTab = 'add',
+  confirmLoading = false,
+  onConfirm,
+  onCancel,
+}) => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+
+  const normalizedAddModels = useMemo(
+    () => normalizeModels(addModels),
+    [addModels],
+  );
+  const normalizedRemoveModels = useMemo(
+    () => normalizeModels(removeModels),
+    [removeModels],
+  );
+
+  const [selectedAddModels, setSelectedAddModels] = useState([]);
+  const [selectedRemoveModels, setSelectedRemoveModels] = useState([]);
+  const [keyword, setKeyword] = useState('');
+  const [activeTab, setActiveTab] = useState('add');
+  const [partialSubmitConfirmed, setPartialSubmitConfirmed] = useState(false);
+
+  const addTabEnabled = normalizedAddModels.length > 0;
+  const removeTabEnabled = normalizedRemoveModels.length > 0;
+  const filteredAddModels = useMemo(
+    () => filterByKeyword(normalizedAddModels, keyword),
+    [normalizedAddModels, keyword],
+  );
+  const filteredRemoveModels = useMemo(
+    () => filterByKeyword(normalizedRemoveModels, keyword),
+    [normalizedRemoveModels, keyword],
+  );
+
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    setSelectedAddModels([]);
+    setSelectedRemoveModels([]);
+    setKeyword('');
+    setPartialSubmitConfirmed(false);
+    const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
+    if (normalizedPreferredTab === 'remove' && removeTabEnabled) {
+      setActiveTab('remove');
+      return;
+    }
+    if (normalizedPreferredTab === 'add' && addTabEnabled) {
+      setActiveTab('add');
+      return;
+    }
+    setActiveTab(addTabEnabled ? 'add' : 'remove');
+  }, [visible, addTabEnabled, removeTabEnabled, preferredTab]);
+
+  const currentModels =
+    activeTab === 'add' ? filteredAddModels : filteredRemoveModels;
+  const currentSelectedModels =
+    activeTab === 'add' ? selectedAddModels : selectedRemoveModels;
+  const currentSetSelectedModels =
+    activeTab === 'add' ? setSelectedAddModels : setSelectedRemoveModels;
+  const selectedAddCount = selectedAddModels.length;
+  const selectedRemoveCount = selectedRemoveModels.length;
+  const checkedCount = currentModels.filter((model) =>
+    currentSelectedModels.includes(model),
+  ).length;
+  const isAllChecked =
+    currentModels.length > 0 && checkedCount === currentModels.length;
+  const isIndeterminate =
+    checkedCount > 0 && checkedCount < currentModels.length;
+
+  const handleToggleAllCurrent = (checked) => {
+    if (checked) {
+      const merged = normalizeModels([
+        ...currentSelectedModels,
+        ...currentModels,
+      ]);
+      currentSetSelectedModels(merged);
+      return;
+    }
+    const currentSet = new Set(currentModels);
+    currentSetSelectedModels(
+      currentSelectedModels.filter((model) => !currentSet.has(model)),
+    );
+  };
+
+  const tabList = [
+    {
+      itemKey: 'add',
+      tab: `${t('新增模型')} (${selectedAddCount}/${normalizedAddModels.length})`,
+      disabled: !addTabEnabled,
+    },
+    {
+      itemKey: 'remove',
+      tab: `${t('删除模型')} (${selectedRemoveCount}/${normalizedRemoveModels.length})`,
+      disabled: !removeTabEnabled,
+    },
+  ];
+
+  const submitSelectedChanges = () => {
+    onConfirm?.({
+      addModels: selectedAddModels,
+      removeModels: selectedRemoveModels,
+    });
+  };
+
+  const handleSubmit = () => {
+    const hasAnySelected = selectedAddCount > 0 || selectedRemoveCount > 0;
+    if (!hasAnySelected) {
+      submitSelectedChanges();
+      return;
+    }
+
+    const hasBothPending = addTabEnabled && removeTabEnabled;
+    const hasUnselectedAdd = addTabEnabled && selectedAddCount === 0;
+    const hasUnselectedRemove = removeTabEnabled && selectedRemoveCount === 0;
+    if (hasBothPending && (hasUnselectedAdd || hasUnselectedRemove)) {
+      if (partialSubmitConfirmed) {
+        submitSelectedChanges();
+        return;
+      }
+      const missingTab = hasUnselectedAdd ? 'add' : 'remove';
+      const missingType = hasUnselectedAdd ? t('新增') : t('删除');
+      const missingCount = hasUnselectedAdd
+        ? normalizedAddModels.length
+        : normalizedRemoveModels.length;
+      setActiveTab(missingTab);
+      Modal.confirm({
+        title: t('仍有未处理项'),
+        content: t(
+          '你还没有处理{{type}}模型({{count}}个)。是否仅提交当前已勾选内容?',
+          {
+            type: missingType,
+            count: missingCount,
+          },
+        ),
+        okText: t('仅提交已勾选'),
+        cancelText: t('去处理{{type}}', { type: missingType }),
+        centered: true,
+        onOk: () => {
+          setPartialSubmitConfirmed(true);
+          submitSelectedChanges();
+        },
+      });
+      return;
+    }
+
+    submitSelectedChanges();
+  };
+
+  return (
+    <Modal
+      visible={visible}
+      title={t('处理上游模型更新')}
+      okText={t('确定')}
+      cancelText={t('取消')}
+      size={isMobile ? 'full-width' : 'medium'}
+      centered
+      closeOnEsc
+      maskClosable
+      confirmLoading={confirmLoading}
+      onCancel={onCancel}
+      onOk={handleSubmit}
+    >
+      <div className='flex flex-col gap-3'>
+        <Typography.Text type='secondary' size='small'>
+          {t(
+            '可勾选需要执行的变更:新增会加入渠道模型列表,删除会从渠道模型列表移除。',
+          )}
+        </Typography.Text>
+
+        <Tabs
+          type='slash'
+          size='small'
+          tabList={tabList}
+          activeKey={activeTab}
+          onChange={(key) => setActiveTab(key)}
+        />
+        <div className='flex items-center gap-3 text-xs text-gray-500'>
+          <span>
+            {t('新增已选 {{selected}} / {{total}}', {
+              selected: selectedAddCount,
+              total: normalizedAddModels.length,
+            })}
+          </span>
+          <span>
+            {t('删除已选 {{selected}} / {{total}}', {
+              selected: selectedRemoveCount,
+              total: normalizedRemoveModels.length,
+            })}
+          </span>
+        </div>
+
+        <Input
+          prefix={<IconSearch size={14} />}
+          placeholder={t('搜索模型')}
+          value={keyword}
+          onChange={(value) => setKeyword(value)}
+          showClear
+        />
+
+        <div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 8 }}>
+          {currentModels.length === 0 ? (
+            <Empty
+              image={
+                <IllustrationNoResult style={{ width: 150, height: 150 }} />
+              }
+              darkModeImage={
+                <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
+              }
+              description={t('暂无匹配模型')}
+              style={{ padding: 24 }}
+            />
+          ) : (
+            <Checkbox.Group
+              value={currentSelectedModels}
+              onChange={(values) =>
+                currentSetSelectedModels(normalizeModels(values))
+              }
+            >
+              <div className='grid grid-cols-1 md:grid-cols-2 gap-x-4'>
+                {currentModels.map((model) => (
+                  <Checkbox
+                    key={`${activeTab}:${model}`}
+                    value={model}
+                    className='my-1'
+                  >
+                    {model}
+                  </Checkbox>
+                ))}
+              </div>
+            </Checkbox.Group>
+          )}
+        </div>
+
+        <div className='flex items-center justify-end gap-2'>
+          <Typography.Text type='secondary' size='small'>
+            {t('已选择 {{selected}} / {{total}}', {
+              selected: checkedCount,
+              total: currentModels.length,
+            })}
+          </Typography.Text>
+          <Checkbox
+            checked={isAllChecked}
+            indeterminate={isIndeterminate}
+            aria-label={t('全选当前列表模型')}
+            onChange={(e) => handleToggleAllCurrent(e.target.checked)}
+          />
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default ChannelUpstreamUpdateModal;

+ 272 - 100
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -27,7 +27,7 @@ import {
   verifyJSON,
 } from '../../../../helpers';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
-import { CHANNEL_OPTIONS } from '../../../../constants';
+import { CHANNEL_OPTIONS, MODEL_FETCHABLE_CHANNEL_TYPES } from '../../../../constants';
 import {
   SideSheet,
   Space,
@@ -100,6 +100,7 @@ const REGION_EXAMPLE = {
   'gemini-1.5-flash-002': 'europe-west2',
   'claude-3-5-sonnet-20240620': 'europe-west1',
 };
+const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8;
 
 const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
   temperature: 0,
@@ -203,6 +204,11 @@ const EditChannelModal = (props) => {
     allow_include_obfuscation: false,
     allow_inference_geo: false,
     claude_beta_query: false,
+    upstream_model_update_check_enabled: false,
+    upstream_model_update_auto_sync_enabled: false,
+    upstream_model_update_last_check_time: 0,
+    upstream_model_update_last_detected_models: [],
+    upstream_model_update_ignored_models: '',
   };
   const [batch, setBatch] = useState(false);
   const [multiToSingle, setMultiToSingle] = useState(false);
@@ -257,6 +263,23 @@ const EditChannelModal = (props) => {
       return [];
     }
   }, [inputs.model_mapping]);
+  const upstreamDetectedModels = useMemo(
+    () =>
+      Array.from(
+        new Set(
+          (inputs.upstream_model_update_last_detected_models || [])
+            .map((model) => String(model || '').trim())
+            .filter(Boolean),
+        ),
+      ),
+    [inputs.upstream_model_update_last_detected_models],
+  );
+  const upstreamDetectedModelsPreview = useMemo(
+    () => upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT),
+    [upstreamDetectedModels],
+  );
+  const upstreamDetectedModelsOmittedCount =
+    upstreamDetectedModels.length - upstreamDetectedModelsPreview.length;
   const modelSearchMatchedCount = useMemo(() => {
     const keyword = modelSearchValue.trim();
     if (!keyword) {
@@ -665,6 +688,14 @@ const EditChannelModal = (props) => {
     }
   };
 
+  const formatUnixTime = (timestamp) => {
+    const value = Number(timestamp || 0);
+    if (!value) {
+      return t('暂无');
+    }
+    return new Date(value * 1000).toLocaleString();
+  };
+
   const copyParamOverrideJson = async () => {
     const raw =
       typeof inputs.param_override === 'string'
@@ -854,6 +885,22 @@ const EditChannelModal = (props) => {
           data.allow_inference_geo =
             parsedSettings.allow_inference_geo || false;
           data.claude_beta_query = parsedSettings.claude_beta_query || false;
+          data.upstream_model_update_check_enabled =
+            parsedSettings.upstream_model_update_check_enabled === true;
+          data.upstream_model_update_auto_sync_enabled =
+            parsedSettings.upstream_model_update_auto_sync_enabled === true;
+          data.upstream_model_update_last_check_time =
+            Number(parsedSettings.upstream_model_update_last_check_time) || 0;
+          data.upstream_model_update_last_detected_models = Array.isArray(
+            parsedSettings.upstream_model_update_last_detected_models,
+          )
+            ? parsedSettings.upstream_model_update_last_detected_models
+            : [];
+          data.upstream_model_update_ignored_models = Array.isArray(
+            parsedSettings.upstream_model_update_ignored_models,
+          )
+            ? parsedSettings.upstream_model_update_ignored_models.join(',')
+            : '';
         } catch (error) {
           console.error('解析其他设置失败:', error);
           data.azure_responses_version = '';
@@ -867,6 +914,11 @@ const EditChannelModal = (props) => {
           data.allow_include_obfuscation = false;
           data.allow_inference_geo = false;
           data.claude_beta_query = false;
+          data.upstream_model_update_check_enabled = false;
+          data.upstream_model_update_auto_sync_enabled = false;
+          data.upstream_model_update_last_check_time = 0;
+          data.upstream_model_update_last_detected_models = [];
+          data.upstream_model_update_ignored_models = '';
         }
       } else {
         // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
@@ -879,6 +931,11 @@ const EditChannelModal = (props) => {
         data.allow_include_obfuscation = false;
         data.allow_inference_geo = false;
         data.claude_beta_query = false;
+        data.upstream_model_update_check_enabled = false;
+        data.upstream_model_update_auto_sync_enabled = false;
+        data.upstream_model_update_last_check_time = 0;
+        data.upstream_model_update_last_detected_models = [];
+        data.upstream_model_update_ignored_models = '';
       }
 
       if (
@@ -1009,7 +1066,7 @@ const EditChannelModal = (props) => {
     const mappingKey = String(pairKey ?? '').trim();
     if (!mappingKey) return;
 
-    if (!MODEL_FETCHABLE_TYPES.has(inputs.type)) {
+    if (!MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type)) {
       return;
     }
 
@@ -1681,6 +1738,29 @@ const EditChannelModal = (props) => {
       }
     }
 
+    settings.upstream_model_update_check_enabled =
+      localInputs.upstream_model_update_check_enabled === true;
+    settings.upstream_model_update_auto_sync_enabled =
+      settings.upstream_model_update_check_enabled &&
+      localInputs.upstream_model_update_auto_sync_enabled === true;
+    settings.upstream_model_update_ignored_models = Array.from(
+      new Set(
+        String(localInputs.upstream_model_update_ignored_models || '')
+          .split(',')
+          .map((model) => model.trim())
+          .filter(Boolean),
+      ),
+    );
+    if (
+      !Array.isArray(settings.upstream_model_update_last_detected_models) ||
+      !settings.upstream_model_update_check_enabled
+    ) {
+      settings.upstream_model_update_last_detected_models = [];
+    }
+    if (typeof settings.upstream_model_update_last_check_time !== 'number') {
+      settings.upstream_model_update_last_check_time = 0;
+    }
+
     localInputs.settings = JSON.stringify(settings);
 
     // 清理不需要发送到后端的字段
@@ -1702,6 +1782,11 @@ const EditChannelModal = (props) => {
     delete localInputs.allow_include_obfuscation;
     delete localInputs.allow_inference_geo;
     delete localInputs.claude_beta_query;
+    delete localInputs.upstream_model_update_check_enabled;
+    delete localInputs.upstream_model_update_auto_sync_enabled;
+    delete localInputs.upstream_model_update_last_check_time;
+    delete localInputs.upstream_model_update_last_detected_models;
+    delete localInputs.upstream_model_update_ignored_models;
 
     let res;
     localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -3080,7 +3165,7 @@ const EditChannelModal = (props) => {
                           >
                             {t('填入所有模型')}
                           </Button>
-                          {MODEL_FETCHABLE_TYPES.has(inputs.type) && (
+                          {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && (
                             <Button
                               size='small'
                               type='tertiary'
@@ -3183,6 +3268,32 @@ const EditChannelModal = (props) => {
                       }
                     />
 
+                    {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && (
+                      <>
+                        <Form.Switch
+                          field='upstream_model_update_check_enabled'
+                          label={t('是否检测上游模型更新')}
+                          checkedText={t('开')}
+                          uncheckedText={t('关')}
+                          onChange={(value) =>
+                            handleChannelOtherSettingsChange(
+                              'upstream_model_update_check_enabled',
+                              value,
+                            )
+                          }
+                          extraText={t(
+                            '开启后由后端定时任务检测该渠道上游模型变化',
+                          )}
+                        />
+                        <div className='text-xs text-gray-500 mb-2'>
+                          {t('上次检测时间')}:&nbsp;
+                          {formatUnixTime(
+                            inputs.upstream_model_update_last_check_time,
+                          )}
+                        </div>
+                      </>
+                    )}
+
                     <Form.Input
                       field='test_model'
                       label={t('默认测试模型')}
@@ -3212,7 +3323,7 @@ const EditChannelModal = (props) => {
                       editorType='keyValue'
                       formApi={formApiRef.current}
                       renderStringValueSuffix={({ pairKey, value }) => {
-                        if (!MODEL_FETCHABLE_TYPES.has(inputs.type)) {
+                        if (!MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type)) {
                           return null;
                         }
                         const disabled = !String(pairKey ?? '').trim();
@@ -3332,31 +3443,93 @@ const EditChannelModal = (props) => {
                       initValue={autoBan}
                     />
 
+                    <Form.Switch
+                        field='upstream_model_update_auto_sync_enabled'
+                        label={t('是否自动同步上游模型更新')}
+                        checkedText={t('开')}
+                        uncheckedText={t('关')}
+                        disabled={!inputs.upstream_model_update_check_enabled}
+                        onChange={(value) =>
+                            handleChannelOtherSettingsChange(
+                                'upstream_model_update_auto_sync_enabled',
+                                value,
+                            )
+                        }
+                        extraText={t(
+                            '开启后检测到新增模型会自动加入当前渠道模型列表',
+                        )}
+                    />
+
+                    <Form.Input
+                        field='upstream_model_update_ignored_models'
+                        label={t('手动忽略模型(逗号分隔)')}
+                        placeholder={t('例如:gpt-4.1-nano,gpt-4o-mini')}
+                        onChange={(value) =>
+                            handleInputChange(
+                                'upstream_model_update_ignored_models',
+                                value,
+                            )
+                        }
+                        showClear
+                    />
+
+                    <div className='text-xs text-gray-500 mb-3'>
+                      {t('上次检测到可加入模型')}:&nbsp;
+                      {upstreamDetectedModels.length === 0 ? (
+                          t('暂无')
+                      ) : (
+                          <>
+                            <Tooltip
+                                position='topLeft'
+                                content={
+                                  <div className='max-w-[640px] break-all text-xs leading-5'>
+                                    {upstreamDetectedModels.join(', ')}
+                                  </div>
+                                }
+                            >
+                            <span className='cursor-help break-all'>
+                              {upstreamDetectedModelsPreview.join(', ')}
+                            </span>
+                            </Tooltip>
+                            <span className='ml-1 text-gray-400'>
+                            {upstreamDetectedModelsOmittedCount > 0
+                                ? t('(共 {{total}} 个,省略 {{omit}} 个)', {
+                                  total: upstreamDetectedModels.length,
+                                  omit: upstreamDetectedModelsOmittedCount,
+                                })
+                                : t('(共 {{total}} 个)', {
+                                  total: upstreamDetectedModels.length,
+                                })}
+                          </span>
+                          </>
+                      )}
+                    </div>
+
                     <div className='mb-4'>
                       <div className='flex items-center justify-between gap-2 mb-1'>
                         <Text className='text-sm font-medium'>{t('参数覆盖')}</Text>
                         <Space wrap>
                           <Button
-                            size='small'
-                            type='primary'
-                            icon={<IconCode size={14} />}
-                            onClick={() => setParamOverrideEditorVisible(true)}
+                              size='small'
+                              type='primary'
+                              icon={<IconCode size={14} />}
+                              onClick={() => setParamOverrideEditorVisible(true)}
                           >
                             {t('可视化编辑')}
                           </Button>
                           <Button
-                            size='small'
-                            onClick={() =>
-                              applyParamOverrideTemplate('operations', 'fill')
-                            }
+                              size='small'
+                              onClick={() =>
+                                  applyParamOverrideTemplate('operations', 'fill')
+                              }
                           >
                             {t('填充新模板')}
                           </Button>
                           <Button
-                            size='small'
-                            onClick={() =>
-                              applyParamOverrideTemplate('legacy', 'fill')
-                            }
+                              size='small'
+                              onClick={() =>
+                                  applyParamOverrideTemplate('legacy', 'fill')
+                              }
                           >
                             {t('填充旧模板')}
                           </Button>
@@ -3373,11 +3546,11 @@ const EditChannelModal = (props) => {
                         {t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')}
                       </Text>
                       <div
-                        className='mt-2 rounded-xl p-3'
-                        style={{
-                          backgroundColor: 'var(--semi-color-fill-0)',
-                          border: '1px solid var(--semi-color-fill-2)',
-                        }}
+                          className='mt-2 rounded-xl p-3'
+                          style={{
+                            backgroundColor: 'var(--semi-color-fill-0)',
+                            border: '1px solid var(--semi-color-fill-2)',
+                          }}
                       >
                         <div className='flex items-center justify-between mb-2'>
                           <Tag color={paramOverrideMeta.tagColor}>
@@ -3385,17 +3558,17 @@ const EditChannelModal = (props) => {
                           </Tag>
                           <Space spacing={8}>
                             <Button
-                              size='small'
-                              icon={<IconCopy />}
-                              type='tertiary'
-                              onClick={copyParamOverrideJson}
+                                size='small'
+                                icon={<IconCopy />}
+                                type='tertiary'
+                                onClick={copyParamOverrideJson}
                             >
                               {t('复制')}
                             </Button>
                             <Button
-                              size='small'
-                              type='tertiary'
-                              onClick={() => setParamOverrideEditorVisible(true)}
+                                size='small'
+                                type='tertiary'
+                                onClick={() => setParamOverrideEditorVisible(true)}
                             >
                               {t('编辑')}
                             </Button>
@@ -3408,82 +3581,81 @@ const EditChannelModal = (props) => {
                     </div>
 
                     <Form.TextArea
-                      field='header_override'
-                      label={t('请求头覆盖')}
-                      placeholder={
-                        t('此项可选,用于覆盖请求头参数') +
-                        '\n' +
-                        t('格式示例:') +
-                        '\n{\n  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n  "Authorization": "Bearer {api_key}"\n}'
-                      }
-                      autosize
-                      onChange={(value) =>
-                        handleInputChange('header_override', value)
-                      }
-                      extraText={
-                        <div className='flex flex-col gap-1'>
-                          <div className='flex gap-2 flex-wrap items-center'>
-                            <Text
-                              className='!text-semi-color-primary cursor-pointer'
-                              onClick={() =>
-                                handleInputChange(
-                                  'header_override',
-                                  JSON.stringify(
-                                    {
-                                      '*': true,
-                                      're:^X-Trace-.*$': true,
-                                      'X-Foo': '{client_header:X-Foo}',
-                                      Authorization: 'Bearer {api_key}',
-                                      'User-Agent':
-                                        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
-                                    },
-                                    null,
-                                    2,
-                                  ),
-                                )
-                              }
-                            >
-                              {t('填入模板')}
-                            </Text>
-                            <Text
-                              className='!text-semi-color-primary cursor-pointer'
-                              onClick={() =>
-                                handleInputChange(
-                                  'header_override',
-                                  JSON.stringify(
-                                    {
-                                      '*': true,
-                                    },
-                                    null,
-                                    2,
-                                  ),
-                                )
-                              }
-                            >
-                              {t('填入透传模版')}
-                            </Text>
-                            <Text
-                              className='!text-semi-color-primary cursor-pointer'
-                              onClick={() => formatJsonField('header_override')}
-                            >
-                              {t('格式化')}
-                            </Text>
-                          </div>
-                          <div>
-                            <Text type='tertiary' size='small'>
-                              {t('支持变量:')}
-                            </Text>
-                            <div className='text-xs text-tertiary ml-2'>
-                              <div>
-                                {t('渠道密钥')}: {'{api_key}'}
+                        field='header_override'
+                        label={t('请求头覆盖')}
+                        placeholder={
+                            t('此项可选,用于覆盖请求头参数') +
+                            '\n' +
+                            t('格式示例:') +
+                            '\n{\n  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n  "Authorization": "Bearer {api_key}"\n}'
+                        }
+                        autosize
+                        onChange={(value) =>
+                            handleInputChange('header_override', value)
+                        }
+                        extraText={
+                          <div className='flex flex-col gap-1'>
+                            <div className='flex gap-2 flex-wrap items-center'>
+                              <Text
+                                  className='!text-semi-color-primary cursor-pointer'
+                                  onClick={() =>
+                                      handleInputChange(
+                                          'header_override',
+                                          JSON.stringify(
+                                              {
+                                                '*': true,
+                                                're:^X-Trace-.*$': true,
+                                                'X-Foo': '{client_header:X-Foo}',
+                                                Authorization: 'Bearer {api_key}',
+                                                'User-Agent':
+                                                    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
+                                              },
+                                              null,
+                                              2,
+                                          ),
+                                      )
+                                  }
+                              >
+                                {t('填入模板')}
+                              </Text>
+                              <Text
+                                  className='!text-semi-color-primary cursor-pointer'
+                                  onClick={() =>
+                                      handleInputChange(
+                                          'header_override',
+                                          JSON.stringify(
+                                              {
+                                                '*': true,
+                                              },
+                                              null,
+                                              2,
+                                          ),
+                                      )
+                                  }
+                              >
+                                {t('填入透传模版')}
+                              </Text>
+                              <Text
+                                  className='!text-semi-color-primary cursor-pointer'
+                                  onClick={() => formatJsonField('header_override')}
+                              >
+                                {t('格式化')}
+                              </Text>
+                            </div>
+                            <div>
+                              <Text type='tertiary' size='small'>
+                                {t('支持变量:')}
+                              </Text>
+                              <div className='text-xs text-tertiary ml-2'>
+                                <div>
+                                  {t('渠道密钥')}: {'{api_key}'}
+                                </div>
                               </div>
                             </div>
                           </div>
-                        </div>
-                      }
-                      showClear
+                        }
+                        showClear
                     />
-
                     <JSONEditor
                       key={`status_code_mapping-${isEdit ? channelId : 'new'}`}
                       field='status_code_mapping'

+ 5 - 0
web/src/constants/channel.constants.js

@@ -191,4 +191,9 @@ export const CHANNEL_OPTIONS = [
   },
 ];
 
+// Channel types that support upstream model list fetching in UI.
+export const MODEL_FETCHABLE_CHANNEL_TYPES = new Set([
+  1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
+]);
+
 export const MODEL_TABLE_PAGE_SIZE = 10;

+ 56 - 0
web/src/hooks/channels/upstreamUpdateUtils.js

@@ -0,0 +1,56 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+export const normalizeModelList = (models = []) =>
+  Array.from(
+    new Set(
+      (models || []).map((model) => String(model || '').trim()).filter(Boolean),
+    ),
+  );
+
+export const parseUpstreamUpdateMeta = (settings) => {
+  let parsed = null;
+  if (settings && typeof settings === 'object') {
+    parsed = settings;
+  } else if (typeof settings === 'string') {
+    try {
+      parsed = JSON.parse(settings);
+    } catch (error) {
+      parsed = null;
+    }
+  }
+
+  if (!parsed || typeof parsed !== 'object') {
+    return {
+      enabled: false,
+      pendingAddModels: [],
+      pendingRemoveModels: [],
+    };
+  }
+
+  return {
+    enabled: parsed.upstream_model_update_check_enabled === true,
+    pendingAddModels: normalizeModelList(
+      parsed.upstream_model_update_last_detected_models,
+    ),
+    pendingRemoveModels: normalizeModelList(
+      parsed.upstream_model_update_last_removed_models,
+    ),
+  };
+};

+ 288 - 0
web/src/hooks/channels/useChannelUpstreamUpdates.jsx

@@ -0,0 +1,288 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useRef, useState } from 'react';
+import { API, showError, showInfo, showSuccess } from '../../helpers';
+import { normalizeModelList } from './upstreamUpdateUtils';
+
+export const useChannelUpstreamUpdates = ({ t, refresh }) => {
+  const [showUpstreamUpdateModal, setShowUpstreamUpdateModal] = useState(false);
+  const [upstreamUpdateChannel, setUpstreamUpdateChannel] = useState(null);
+  const [upstreamUpdateAddModels, setUpstreamUpdateAddModels] = useState([]);
+  const [upstreamUpdateRemoveModels, setUpstreamUpdateRemoveModels] = useState(
+    [],
+  );
+  const [upstreamUpdatePreferredTab, setUpstreamUpdatePreferredTab] =
+    useState('add');
+  const [upstreamApplyLoading, setUpstreamApplyLoading] = useState(false);
+  const [detectAllUpstreamUpdatesLoading, setDetectAllUpstreamUpdatesLoading] =
+    useState(false);
+  const [applyAllUpstreamUpdatesLoading, setApplyAllUpstreamUpdatesLoading] =
+    useState(false);
+
+  const applyUpstreamUpdatesInFlightRef = useRef(false);
+  const detectChannelUpstreamUpdatesInFlightRef = useRef(false);
+  const detectAllUpstreamUpdatesInFlightRef = useRef(false);
+  const applyAllUpstreamUpdatesInFlightRef = useRef(false);
+
+  const openUpstreamUpdateModal = (
+    record,
+    pendingAddModels = [],
+    pendingRemoveModels = [],
+    preferredTab = 'add',
+  ) => {
+    const normalizedAddModels = normalizeModelList(pendingAddModels);
+    const normalizedRemoveModels = normalizeModelList(pendingRemoveModels);
+    if (
+      !record?.id ||
+      (normalizedAddModels.length === 0 && normalizedRemoveModels.length === 0)
+    ) {
+      showInfo(t('该渠道暂无可处理的上游模型更新'));
+      return;
+    }
+    setUpstreamUpdateChannel(record);
+    setUpstreamUpdateAddModels(normalizedAddModels);
+    setUpstreamUpdateRemoveModels(normalizedRemoveModels);
+    const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
+    setUpstreamUpdatePreferredTab(normalizedPreferredTab);
+    setShowUpstreamUpdateModal(true);
+  };
+
+  const closeUpstreamUpdateModal = () => {
+    setShowUpstreamUpdateModal(false);
+    setUpstreamUpdateChannel(null);
+    setUpstreamUpdateAddModels([]);
+    setUpstreamUpdateRemoveModels([]);
+    setUpstreamUpdatePreferredTab('add');
+  };
+
+  const applyUpstreamUpdates = async ({
+    addModels: selectedAddModels = [],
+    removeModels: selectedRemoveModels = [],
+  } = {}) => {
+    if (applyUpstreamUpdatesInFlightRef.current) {
+      showInfo(t('正在处理,请稍候'));
+      return;
+    }
+    if (!upstreamUpdateChannel?.id) {
+      closeUpstreamUpdateModal();
+      return;
+    }
+    applyUpstreamUpdatesInFlightRef.current = true;
+    setUpstreamApplyLoading(true);
+
+    try {
+      const normalizedSelectedAddModels = normalizeModelList(selectedAddModels);
+      const normalizedSelectedRemoveModels =
+        normalizeModelList(selectedRemoveModels);
+      const selectedAddSet = new Set(normalizedSelectedAddModels);
+      const ignoreModels = upstreamUpdateAddModels.filter(
+        (model) => !selectedAddSet.has(model),
+      );
+
+      const res = await API.post(
+        '/api/channel/upstream_updates/apply',
+        {
+          id: upstreamUpdateChannel.id,
+          add_models: normalizedSelectedAddModels,
+          ignore_models: ignoreModels,
+          remove_models: normalizedSelectedRemoveModels,
+        },
+        { skipErrorHandler: true },
+      );
+      const { success, message, data } = res.data || {};
+      if (!success) {
+        showError(message || t('操作失败'));
+        return;
+      }
+
+      const addedCount = data?.added_models?.length || 0;
+      const removedCount = data?.removed_models?.length || 0;
+      const ignoredCount = data?.ignored_models?.length || 0;
+      showSuccess(
+        t(
+          '已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,忽略 {{ignored}} 个',
+          {
+            added: addedCount,
+            removed: removedCount,
+            ignored: ignoredCount,
+          },
+        ),
+      );
+      closeUpstreamUpdateModal();
+      await refresh();
+    } catch (error) {
+      showError(
+        error?.response?.data?.message || error?.message || t('操作失败'),
+      );
+    } finally {
+      applyUpstreamUpdatesInFlightRef.current = false;
+      setUpstreamApplyLoading(false);
+    }
+  };
+
+  const applyAllUpstreamUpdates = async () => {
+    if (applyAllUpstreamUpdatesInFlightRef.current) {
+      showInfo(t('正在批量处理,请稍候'));
+      return;
+    }
+    applyAllUpstreamUpdatesInFlightRef.current = true;
+    setApplyAllUpstreamUpdatesLoading(true);
+    try {
+      const res = await API.post(
+        '/api/channel/upstream_updates/apply_all',
+        {},
+        { skipErrorHandler: true },
+      );
+      const { success, message, data } = res.data || {};
+      if (!success) {
+        showError(message || t('批量处理失败'));
+        return;
+      }
+
+      const channelCount = data?.processed_channels || 0;
+      const addedCount = data?.added_models || 0;
+      const removedCount = data?.removed_models || 0;
+      const failedCount = (data?.failed_channel_ids || []).length;
+      showSuccess(
+        t(
+          '已批量处理上游模型更新:渠道 {{channels}} 个,加入 {{added}} 个,删除 {{removed}} 个,失败 {{fails}} 个',
+          {
+            channels: channelCount,
+            added: addedCount,
+            removed: removedCount,
+            fails: failedCount,
+          },
+        ),
+      );
+      await refresh();
+    } catch (error) {
+      showError(
+        error?.response?.data?.message || error?.message || t('批量处理失败'),
+      );
+    } finally {
+      applyAllUpstreamUpdatesInFlightRef.current = false;
+      setApplyAllUpstreamUpdatesLoading(false);
+    }
+  };
+
+  const detectChannelUpstreamUpdates = async (channel) => {
+    if (detectChannelUpstreamUpdatesInFlightRef.current) {
+      showInfo(t('正在检测,请稍候'));
+      return;
+    }
+    if (!channel?.id) {
+      return;
+    }
+    detectChannelUpstreamUpdatesInFlightRef.current = true;
+    try {
+      const res = await API.post(
+        '/api/channel/upstream_updates/detect',
+        {
+          id: channel.id,
+        },
+        { skipErrorHandler: true },
+      );
+      const { success, message, data } = res.data || {};
+      if (!success) {
+        showError(message || t('检测失败'));
+        return;
+      }
+
+      const addCount = data?.add_models?.length || 0;
+      const removeCount = data?.remove_models?.length || 0;
+      showSuccess(
+        t('检测完成:新增 {{add}} 个,删除 {{remove}} 个', {
+          add: addCount,
+          remove: removeCount,
+        }),
+      );
+      await refresh();
+    } catch (error) {
+      showError(
+        error?.response?.data?.message || error?.message || t('检测失败'),
+      );
+    } finally {
+      detectChannelUpstreamUpdatesInFlightRef.current = false;
+    }
+  };
+
+  const detectAllUpstreamUpdates = async () => {
+    if (detectAllUpstreamUpdatesInFlightRef.current) {
+      showInfo(t('正在批量检测,请稍候'));
+      return;
+    }
+    detectAllUpstreamUpdatesInFlightRef.current = true;
+    setDetectAllUpstreamUpdatesLoading(true);
+    try {
+      const res = await API.post(
+        '/api/channel/upstream_updates/detect_all',
+        {},
+        { skipErrorHandler: true },
+      );
+      const { success, message, data } = res.data || {};
+      if (!success) {
+        showError(message || t('批量检测失败'));
+        return;
+      }
+
+      const channelCount = data?.processed_channels || 0;
+      const addCount = data?.detected_add_models || 0;
+      const removeCount = data?.detected_remove_models || 0;
+      const failedCount = (data?.failed_channel_ids || []).length;
+      showSuccess(
+        t(
+          '批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个',
+          {
+            channels: channelCount,
+            add: addCount,
+            remove: removeCount,
+            fails: failedCount,
+          },
+        ),
+      );
+      await refresh();
+    } catch (error) {
+      showError(
+        error?.response?.data?.message || error?.message || t('批量检测失败'),
+      );
+    } finally {
+      detectAllUpstreamUpdatesInFlightRef.current = false;
+      setDetectAllUpstreamUpdatesLoading(false);
+    }
+  };
+
+  return {
+    showUpstreamUpdateModal,
+    setShowUpstreamUpdateModal,
+    upstreamUpdateChannel,
+    upstreamUpdateAddModels,
+    upstreamUpdateRemoveModels,
+    upstreamUpdatePreferredTab,
+    upstreamApplyLoading,
+    detectAllUpstreamUpdatesLoading,
+    applyAllUpstreamUpdatesLoading,
+    openUpstreamUpdateModal,
+    closeUpstreamUpdateModal,
+    applyUpstreamUpdates,
+    applyAllUpstreamUpdates,
+    detectChannelUpstreamUpdates,
+    detectAllUpstreamUpdates,
+  };
+};

+ 8 - 0
web/src/hooks/channels/useChannelsData.jsx

@@ -35,6 +35,8 @@ import {
 } from '../../constants';
 import { useIsMobile } from '../common/useIsMobile';
 import { useTableCompactMode } from '../common/useTableCompactMode';
+import { useChannelUpstreamUpdates } from './useChannelUpstreamUpdates';
+import { parseUpstreamUpdateMeta } from './upstreamUpdateUtils';
 import { Modal, Button } from '@douyinfe/semi-ui';
 import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal';
 
@@ -235,6 +237,9 @@ export const useChannelsData = () => {
     let channelTags = {};
 
     for (let i = 0; i < channels.length; i++) {
+      channels[i].upstreamUpdateMeta = parseUpstreamUpdateMeta(
+        channels[i].settings,
+      );
       channels[i].key = '' + channels[i].id;
       if (!enableTagMode) {
         channelDates.push(channels[i]);
@@ -432,6 +437,8 @@ export const useChannelsData = () => {
     }
   };
 
+  const upstreamUpdates = useChannelUpstreamUpdates({ t, refresh });
+
   // Channel management
   const manageChannel = async (id, action, record, value) => {
     let data = { id };
@@ -1194,6 +1201,7 @@ export const useChannelsData = () => {
     setShowMultiKeyManageModal,
     currentMultiKeyChannel,
     setCurrentMultiKeyChannel,
+    ...upstreamUpdates,
 
     // Form
     formApi,