Files
new-api/controller/channel_upstream_update.go
CaIon c6741c36a9 feat(channel): add a per-channel switch to skip upstream TLS verification
Until now only the global TLS_INSECURE_SKIP_VERIFY setting could accept a
self-signed upstream certificate. A channel's tls_insecure_skip_verify
setting now does it for that channel's API requests: relay and the Responses
and Realtime WebSocket dialer, task submission and polling, plugin fetches,
balance, usage and model-list queries, and media proxied through the channel
proxy. Credential exchanges (Codex OAuth refresh, Vertex and plugin
service-account tokens) keep verifying certificates. The channel drawer shows
the switch under the HTTP protocol settings.
2026-10-10 14:02:06 +08:00

1161 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"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 channelUpstreamModelUpdateSelectFields = []string{
"id",
"name",
"type",
"key",
"status",
"base_url",
"models",
"model_mapping",
"settings",
"setting",
"other",
"group",
"priority",
"weight",
"tag",
"channel_info",
"header_override",
}
var 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{}{}
}
normalizedIgnoredModels := normalizeModelNames(ignoredModels)
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 lo.ContainsBy(normalizedIgnoredModels, func(ignoredModel string) bool {
if regexBody, ok := strings.CutPrefix(ignoredModel, "regex:"); ok {
matched, err := regexp.MatchString(strings.TrimSpace(regexBody), modelName)
return err == nil && matched
}
return ignoredModel == modelName
}) {
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 parseOpenAIModelIDs(body []byte) ([]string, error) {
var result struct {
Data *[]OpenAIModel `json:"data"`
}
if err := common.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("invalid OpenAI Models response: %w", err)
}
if result.Data == nil {
return nil, fmt.Errorf("invalid OpenAI Models response: data is required")
}
ids := normalizeModelNames(lo.Map(*result.Data, func(item OpenAIModel, _ int) string {
return item.ID
}))
if len(ids) == 0 {
return nil, fmt.Errorf("OpenAI Models response contains no valid model IDs")
}
return ids, nil
}
func sanitizeFetchModelsError(err error, key string) error {
if err == nil {
return nil
}
// net/http includes the complete request URL in url.Error. Discovery routes
// may put the API key in a custom query name or value, so never return that
// wrapper to an API client.
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
err = urlErr.Err
}
message := err.Error()
key = strings.TrimSpace(key)
if key != "" {
message = strings.ReplaceAll(message, key, "[REDACTED]")
message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
}
return errors.New(message)
}
func sanitizeAdvancedCustomRequestError(err error, key string, requestURL string) error {
err = sanitizeFetchModelsError(err, key)
if err == nil {
return nil
}
parsedURL, parseErr := url.Parse(requestURL)
if parseErr != nil {
return err
}
message := err.Error()
for _, value := range parsedURL.Query() {
for _, secret := range value {
if secret == "" {
continue
}
message = strings.ReplaceAll(message, secret, "[REDACTED]")
message = strings.ReplaceAll(message, url.QueryEscape(secret), "[REDACTED]")
message = strings.ReplaceAll(message, url.PathEscape(secret), "[REDACTED]")
}
}
if key != "" {
message = strings.ReplaceAll(message, key, "[REDACTED]")
message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
}
return errors.New(message)
}
func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) {
request, err := http.NewRequest(method, requestURL, nil)
if err != nil {
return nil, err
}
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
if strings.EqualFold(name, "Host") {
request.Host = headers.Get(name)
}
}
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d", response.StatusCode)
}
return io.ReadAll(response.Body)
}
func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
if channel.Type == constant.ChannelTypeTaskPlugin {
plugin, ok := jsplugin.DefaultRegistry.Get(channel.GetSetting().TaskPluginKey)
if !ok {
return nil, fmt.Errorf("task plugin %q is not registered", channel.GetSetting().TaskPluginKey)
}
return normalizeModelNames(plugin.Meta.Models), nil
}
baseURL := constant.GetChannelBaseURL(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("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting())
if err != nil {
return nil, err
}
return normalizeModelNames(models), nil
}
if constant.IsAdvancedCustomChannel(channel.Type) {
return fetchAdvancedCustomUpstreamModelIDs(channel, baseURL)
}
if channel.Type == constant.ChannelTypeCodex {
return service.FetchCodexChannelModels(channel)
}
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:
// 火山方舟 OpenAI 兼容 API 根路径是 /api/v3(chat/embeddings 均为
// {base}/api/v3/...,见 relay/channel/volcengine 的 GetRequestURL),
// 不存在 /v1/models 端点。原拼接会导致「获取模型列表」固定 404 失败。
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/api/v3/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("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
headers, err := buildFetchModelsHeaders(channel, key)
if err != nil {
return nil, sanitizeFetchModelsError(err, key)
}
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
if err != nil {
return nil, sanitizeAdvancedCustomRequestError(err, key, url)
}
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 fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) {
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
RelayMode: relayconstant.RelayModeUnknown,
RequestURLPath: dto.AdvancedCustomModelListPath,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: channel.Type,
ChannelBaseUrl: baseURL,
ApiKey: key,
ChannelOtherSettings: channel.GetOtherSettings(),
},
}
adaptor := &advancedcustom.Adaptor{}
url, headers, err := adaptor.BuildModelListRequest(info)
if err != nil {
return nil, sanitizeFetchModelsError(err, key)
}
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
return nil, sanitizeFetchModelsError(err, key)
}
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
if err != nil {
return nil, sanitizeFetchModelsError(err, key)
}
return parseOpenAIModelIDs(body)
}
func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error {
channel.SetOtherSettings(settings)
updates := map[string]any{
"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(common.LogText("InitChannelCache panic: %v", r))
}
}()
model.InitChannelCache()
}()
}
}
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(
lang string,
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(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateSummary, map[string]any{
"Checked": checkedChannels,
"Changed": changedChannels,
"Added": detectedAddModels,
"Removed": detectedRemoveModels,
"AutoAdded": autoAddedModels,
"Failed": failedChannels,
}))
if len(channelSummaries) > 0 {
displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateChangedChannels, map[string]any{
"Shown": displayCount,
"Total": 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("\n- ")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{
"Count": len(channelSummaries) - displayCount,
}))
}
}
normalizedAddModelSamples := normalizeModelNames(addModelSamples)
if len(normalizedAddModelSamples) > 0 {
displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateAddedModels, map[string]any{
"Shown": displayCount,
"Total": len(normalizedAddModelSamples),
"Models": strings.Join(normalizedAddModelSamples[:displayCount], ", "),
}))
if len(normalizedAddModelSamples) > displayCount {
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": len(normalizedAddModelSamples) - displayCount,
}))
}
}
normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
if len(normalizedRemoveModelSamples) > 0 {
displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateRemovedModels, map[string]any{
"Shown": displayCount,
"Total": len(normalizedRemoveModelSamples),
"Models": strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
}))
if len(normalizedRemoveModelSamples) > displayCount {
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": 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("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
"Shown": displayCount,
"Total": failedChannels,
"Ids": strings.Join(displayIDs, ", "),
}))
if failedChannels > displayCount {
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": failedChannels - displayCount,
}))
}
}
return builder.String()
}
type upstreamModelUpdateSummary struct {
CheckedChannels int `json:"checked_channels"`
ChangedChannels int `json:"changed_channels"`
DetectedAddModels int `json:"detected_add_models"`
DetectedRemoveModels int `json:"detected_remove_models"`
FailedChannels int `json:"failed_channels"`
AutoAddedModels int `json:"auto_added_models"`
}
// runChannelUpstreamModelUpdateTaskOnce runs one synchronous upstream model
// detection cycle and returns a summary for system task history. It honors ctx
// cancellation between batches so a runner that loses its lease stops promptly.
// force bypasses the per-channel minimum check interval and allowAutoApply lets
// channels with auto-sync enabled adopt detected models automatically. The
// scheduled job calls (force=false, allowAutoApply=true); the manual "detect
// all" trigger calls (force=true, allowAutoApply=false) so it always re-checks
// and only stages changes for explicit review.
func runChannelUpstreamModelUpdateTaskOnce(ctx context.Context, force bool, allowAutoApply bool, report func(processed, total int)) upstreamModelUpdateSummary {
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
// Count the enabled channels up front so progress can be reported as a
// percentage; a count error is non-fatal (progress just won't show a %).
var totalChannels int64
if err := model.DB.Model(&model.Channel{}).Where("status = ?", common.ChannelStatusEnabled).Count(&totalChannels).Error; err != nil {
totalChannels = 0
}
processed := 0
lastID := 0
scanLoop:
for {
if ctx != nil && ctx.Err() != nil {
break
}
var channels []*model.Channel
query := model.DB.
Select(channelUpstreamModelUpdateSelectFields).
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(common.LogText("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
}
if ctx != nil && ctx.Err() != nil {
break scanLoop
}
processed++
if report != nil {
report(processed, int(totalChannels))
}
settings := channel.GetOtherSettings()
if !settings.UpstreamModelUpdateCheckEnabled {
continue
}
checkedChannels++
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, force, allowAutoApply)
if err != nil {
failedChannels++
failedChannelIDs = append(failedChannelIDs, channel.Id)
common.SysLog(common.LogText("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 {
if ctx == nil {
time.Sleep(common.RequestInterval)
} else {
select {
case <-ctx.Done():
break scanLoop
case <-time.After(common.RequestInterval):
}
}
}
}
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
break
}
}
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(int(totalChannels), int(totalChannels)) // mark complete only when the full scan finished
}
if refreshNeeded {
refreshChannelRuntimeCache()
}
summary := upstreamModelUpdateSummary{
CheckedChannels: checkedChannels,
ChangedChannels: changedChannels,
DetectedAddModels: detectedAddModels,
DetectedRemoveModels: detectedRemoveModels,
FailedChannels: failedChannels,
AutoAddedModels: autoAddedModels,
}
if checkedChannels > 0 || common.DebugEnabled {
common.SysLog(common.LogText(
"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(common.LogText(
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
changedChannels,
failedChannels,
))
return summary
}
service.NotifyUpstreamModelUpdateWatchers(func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateNotifySubject),
buildUpstreamModelUpdateTaskNotificationContent(
lang,
checkedChannels,
changedChannels,
detectedAddModels,
detectedRemoveModels,
autoAddedModels,
failedChannelIDs,
channelSummaries,
addModelSamples,
removeModelSamples,
)
})
}
return summary
}
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()
}
recordManageAudit(c, "channel.upstream_apply", map[string]any{
"id": channel.Id,
})
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()
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(channelUpstreamModelUpdateSelectFields).
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()
}
recordManageAudit(c, "channel.upstream_apply_all", map[string]any{
"count": len(results),
})
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,
},
})
}
// DetectAllChannelUpstreamModelUpdates enqueues a model_update system task
// (manual variant) instead of scanning inline. Routing the manual trigger
// through the framework gives it the same cross-instance lease dedup and run
// history as the scheduled scan. If any model_update task is already active, the
// manual run is rejected so the caller does not mistake a scheduled run for this
// manual one.
func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true})
if err != nil {
common.ApiError(c, err)
return
}
if !created {
msg := common.NewMessage("A model update task is already running or queued. Cannot start this manual task")
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
}})
return
}
recordManageAudit(c, "channel.upstream_detect_all", map[string]any{
"task_id": task.TaskID,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
},
})
}