Skip to content
Closed

merge #6405

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a58feff
fix: reject unpriced image generation with 200
krismileji Jul 11, 2026
873543d
Merge branch 'QuantumNous:main' into main
krismileji Jul 14, 2026
91fd304
fix: add scoped retries for Cloudflare 524
krismileji Jul 14, 2026
f81b274
Merge branch 'QuantumNous:main' into main
krismileji Jul 15, 2026
de0ab42
Merge branch 'QuantumNous:main' into main
krismileji Jul 17, 2026
1ac7cc6
Merge branch 'QuantumNous:main' into main
krismileji Jul 18, 2026
9a11ffb
fix: add scoped retries for transient upstream failures
krismileji Jul 15, 2026
afec01a
fix: show only final retry result to users
krismileji Jul 17, 2026
5a0df93
feat: add channel monitoring and smart scheduling
krismileji Jul 18, 2026
3305400
feat: enhance channel monitoring upstream management
krismileji Jul 18, 2026
8450ddf
feat: refine Sub2API channel monitoring integration
krismileji Jul 19, 2026
4371fcb
fix: avoid default upstream group on first setup
krismileji Jul 19, 2026
4ad6b9f
fix: improve channel monitor proxy handling and sorting
krismileji Jul 19, 2026
9cf0828
fix: sanitize user errors and continue configured retries
krismileji Jul 19, 2026
513e15a
feat: refine smart channel scheduling
krismileji Jul 19, 2026
d9dab76
feat: add custom upstream channel monitoring
krismileji Jul 19, 2026
4457aea
refactor: use fixed Chinese in channel monitor
krismileji Jul 19, 2026
9fd4754
docs: add downstream fork maintenance rules
Jul 20, 2026
ffceefd
fix: reset smart schedule state before recalculation
Jul 20, 2026
93ce3b6
docs: require Chinese for downstream features
Jul 20, 2026
b740d88
feat: auto-disable channels on low upstream balance
Jul 20, 2026
384bd70
fix(web): prevent duplicate channel monitor error toasts
Jul 20, 2026
ddc1e17
fix: refine smart schedule reset and retry routing
Jul 20, 2026
d68559a
fix: reset channel scheduling values on opt-in
krismileji Jul 20, 2026
59712cd
feat: add channel monitor failure auto-disable
krismileji Jul 20, 2026
c9fe3fd
feat: add channel success rate details
Jul 21, 2026
bc2baf9
feat: manage channel group memberships
krismileji Jul 21, 2026
9fe0e95
feat: configure channel monitor performance range
krismileji Jul 21, 2026
b6c2dc2
feat: add channel batch connectivity tests
krismileji Jul 21, 2026
a8ee25a
feat: add Sub2API account authentication
krismileji Jul 22, 2026
4e80e6e
feat: add channel group removal policy
krismileji Jul 22, 2026
7962027
feat: include channel remarks in monitor emails
krismileji Jul 22, 2026
576c44f
feat: enhance channel monitor controls
krismileji Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .agents/downstream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Downstream Fork Rules

This repository is a downstream fork of the official project. Keeping the
working tree compatible with `upstream/main` and minimizing future merge
conflicts are primary engineering constraints.

## Upstream Baseline

- Treat `upstream/main` as the official source baseline and `origin/main` as
the downstream branch.
- A path that exists in `upstream/main` is upstream-owned even when the
downstream branch has already modified it. A path introduced only in the
downstream branch is downstream-owned.
- Before changing existing code, compare the relevant files or history with
`upstream/main` when the upstream remote is available.
- If the upstream remote or baseline is unavailable, do not make broad
refactors based on assumptions about ownership; state the limitation.

## Change Placement

- Leave upstream-owned files unchanged by default. Before editing one, first
determine whether the requirement can be met without that edit.
- Prefer existing configuration, extension points, registration tables,
interfaces, and middleware hooks.
- Prefer adding a new file within the existing package or feature for
downstream behavior. Keep the existing architecture and ownership model.
- Do not duplicate substantial upstream logic only to avoid touching an
upstream-owned file.
- A required bug fix or integration may modify an upstream-owned file. When
that is necessary, keep the change to the smallest possible number of
files and hunks, and isolate downstream behavior behind a narrow hook or
adapter where practical.

## Conflict Avoidance

- Do not perform unrelated cleanup, refactoring, renaming, moving, import
reordering, whole-file formatting, dependency upgrades, or generated-file
changes as part of a feature or bug fix.
- Preserve upstream APIs, naming, layout, and behavior unless the task
explicitly requires a change.
- Keep upstream synchronization commits separate from downstream feature
commits.
- When resolving an upstream merge, preserve the upstream implementation
first, then reapply the smallest downstream integration necessary.

## Downstream Language

- All downstream-only user-facing text MUST use Simplified Chinese literals,
including frontend UI copy and backend API messages or notifications.
- Do not add frontend or backend i18n keys, translation calls, static
translation keys, locale messages, or locale entries for downstream-only
features.
- Do not modify locale files for downstream-only features.
- Preserve the official project's existing internationalization behavior when
editing upstream-owned features. This downstream-only exception overrides
the root i18n rules only for code and features owned by this fork.

## Review Before Completion

- Inspect `git diff --stat` and `git diff --check` before finishing.
- Identify every existing upstream-owned file changed by the task and record
why the change was necessary.
- Prefer solutions that could be accepted upstream without introducing
fork-specific coupling, even when the final implementation remains in this
downstream repository.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

DO NOT send optional commentary

Before making changes in this downstream fork, you MUST read and follow `.agents/downstream.md`.

## Overview

This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI providers (OpenAI, Claude, Gemini, Azure, AWS Bedrock, etc.) behind a unified API, with user management, billing, rate limiting, and an admin dashboard.
Expand Down
27 changes: 25 additions & 2 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,32 @@ var auditContentTemplates = map[string]string{
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
}

// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
// channelMonitorAuditContentTemplates 是自定义渠道监控功能的固定中文日志模板。
// 该页面仅供内部使用,不跟随系统语言切换。
var channelMonitorAuditContentTemplates = map[string]string{
"channel.status_update": "已将渠道 ${id} 的状态更新为 ${status}",
"channel.monitor_smart_schedule_config_update": "已更新渠道 ${id} 的智能调度设置",
"channel.monitor_group_ratio_sync": "已根据成本倍率 ${cost_ratio}(上游倍率 ${upstream_ratio} × 换算系数 ${conversion_factor})和分组系数 ${coefficient},将分组 ${group} 的倍率更新为 ${ratio}",
"channel.monitor_group_ratio_update": "已将分组 ${group} 的倍率更新为 ${ratio}",
"channel.monitor_group_channels_update": "已更新分组 ${group} 的关联渠道(新增 ${added_count} 个,移除 ${removed_count} 个)",
"channel.monitor_ratio_update": "已将渠道 ${id} 的倍率更新为 ${ratio}",
"channel.monitor_ratio_update_run": "已启动上游倍率更新任务 ${task_id}",
"channel.monitor_upstream_config_update": "已更新渠道 ${id} 的上游配置(${upstream_type_label},成本换算:${cost_conversion},换算系数 ${conversion_factor})",
"channel.monitor_upstream_ratio_fetch": "已获取渠道 ${id} 的上游倍率 ${ratio},换算后成本倍率 ${cost_ratio}(系数 ${conversion_factor})",
"channel.monitor_upstream_balance_fetch": "已获取渠道 ${id} 的上游余额 ${balance}",
"channel.monitor_upstream_group_apply": "已将上游分组 ${group} 应用于渠道 ${id}(已更新 ${keys_updated} 个令牌,上游倍率 ${ratio},成本倍率 ${cost_ratio})",
"channel.monitor_smart_schedule_run": "已启动智能调度任务 ${task_id}",
"channel.monitor_order_update": "已更新 ${channel_count} 个监控渠道的自定义顺序",
"channel.monitor_settings_update": "已更新渠道监控设置",
}

// auditContentEN 渲染日志兜底文本;渠道监控使用固定中文,其余操作使用英文基线。
// 未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]interface{}) string {
tmpl, ok := auditContentTemplates[action]
tmpl, ok := channelMonitorAuditContentTemplates[action]
if !ok {
tmpl, ok = auditContentTemplates[action]
}
if !ok {
return action
}
Expand Down
2 changes: 1 addition & 1 deletion controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -957,7 +957,7 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse

// disable channel
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, false)
summary.Disabled++
}

Expand Down
222 changes: 222 additions & 0 deletions controller/channel_monitor_cost.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package controller

import (
"context"
"errors"
"math"
"net/http"
"sort"
"strconv"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"

"github.com/gin-gonic/gin"
)

const (
channelMonitorCostDefaultDays = 30
channelMonitorCostMaxDays = 90
channelMonitorCostDaySeconds = int64(24 * 60 * 60)
channelMonitorCostOffset = int64(8 * 60 * 60)
)

type channelMonitorCostDay struct {
Date string `json:"date"`
StartAt int64 `json:"start_at"`
CostCNY float64 `json:"cost_cny"`
}

type channelMonitorCostChannel struct {
ChannelId int `json:"channel_id"`
ChannelName string `json:"channel_name"`
CostCNY float64 `json:"cost_cny"`
}

type channelMonitorCostCoverage struct {
IncludedChannelCount int `json:"included_channel_count"`
UnresolvedChannelCount int `json:"unresolved_channel_count"`
FreeGroupChannelCount int `json:"free_group_channel_count"`
}

type channelMonitorCostOverview struct {
Days int `json:"days"`
GeneratedAt int64 `json:"generated_at"`
TodayCostCNY float64 `json:"today_cost_cny"`
YesterdayCostCNY float64 `json:"yesterday_cost_cny"`
TotalCostCNY float64 `json:"total_cost_cny"`
Coverage channelMonitorCostCoverage `json:"coverage"`
Items []channelMonitorCostDay `json:"items"`
Channels []channelMonitorCostChannel `json:"channels"`
}

func GetChannelMonitorCostOverview(c *gin.Context) {
days := channelMonitorCostDefaultDays
if rawDays := c.Query("days"); rawDays != "" {
parsedDays, err := strconv.Atoi(rawDays)
if err != nil || parsedDays < 1 || parsedDays > channelMonitorCostMaxDays {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "统计天数必须在 1 到 90 之间"})
return
}
days = parsedDays
}

overview, err := getChannelMonitorCostOverview(c.Request.Context(), days, common.GetTimestamp())
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, overview)
}

func getChannelMonitorCostOverview(ctx context.Context, days int, now int64) (channelMonitorCostOverview, error) {
todayStart := channelMonitorCostDayStart(now)
startTimestamp := todayStart - int64(days-1)*channelMonitorCostDaySeconds
endTimestamp := todayStart + channelMonitorCostDaySeconds

quotas, err := model.GetChannelMonitorDailyQuotas(ctx, startTimestamp, endTimestamp)
if err != nil {
return channelMonitorCostOverview{}, err
}
monitors, err := model.GetChannelRatioMonitors()
if err != nil {
return channelMonitorCostOverview{}, err
}
channels, err := model.GetAllChannelsForMonitor()
if err != nil {
return channelMonitorCostOverview{}, err
}

costRatios := make(map[int]float64, len(monitors))
for _, monitor := range monitors {
if monitor.UpdatedTime == 0 {
continue
}
conversion, parseErr := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
if parseErr != nil || conversion.Mode == service.ChannelMonitorCostConversionNone {
continue
}
costRatio, _, ratioErr := service.CalculateChannelMonitorCostRatio(monitor.Ratio, conversion)
if ratioErr != nil || !validChannelMonitorCostValue(costRatio) || costRatio < 0 {
continue
}
costRatios[monitor.ChannelId] = costRatio
}

channelNames := make(map[int]string, len(channels))
for _, channel := range channels {
channelNames[channel.Id] = channel.Name
}
groupRatios := ratio_setting.GetGroupRatioCopy()
quotaPerUnit := common.QuotaPerUnit
if math.IsNaN(quotaPerUnit) || math.IsInf(quotaPerUnit, 0) || quotaPerUnit <= 0 {
return channelMonitorCostOverview{}, errors.New("额度单位配置无效,无法回算渠道成本")
}
dailyCosts := make(map[int64]float64, days)
channelCosts := make(map[int]float64)
includedChannels := make(map[int]struct{})
unresolvedChannels := make(map[int]struct{})
freeGroupChannels := make(map[int]struct{})

for _, quota := range quotas {
if quota.Quota == 0 {
continue
}
costRatio, configured := costRatios[quota.ChannelId]
if !configured {
unresolvedChannels[quota.ChannelId] = struct{}{}
continue
}
groupRatio, exists := groupRatios[quota.Group]
if !exists {
groupRatio = 1
}
if math.IsNaN(groupRatio) || math.IsInf(groupRatio, 0) || groupRatio <= 0 {
freeGroupChannels[quota.ChannelId] = struct{}{}
continue
}

baseCostUSD := float64(quota.Quota) / quotaPerUnit / groupRatio
costCNY := baseCostUSD * costRatio
if !validChannelMonitorCostValue(costCNY) {
common.SysError("渠道监控成本统计跳过异常成本值")
continue
}
dailyCosts[quota.DayStart] += costCNY
channelCosts[quota.ChannelId] += costCNY
includedChannels[quota.ChannelId] = struct{}{}
}

items := make([]channelMonitorCostDay, 0, days)
totalCostCNY := 0.0
for dayStart := startTimestamp; dayStart < endTimestamp; dayStart += channelMonitorCostDaySeconds {
costCNY := dailyCosts[dayStart]
if !validChannelMonitorCostValue(costCNY) {
common.SysError("渠道监控成本统计跳过异常每日汇总值")
costCNY = 0
}
items = append(items, channelMonitorCostDay{
Date: channelMonitorCostDate(dayStart),
StartAt: dayStart,
CostCNY: costCNY,
})
totalCostCNY += costCNY
}

costChannels := make([]channelMonitorCostChannel, 0, len(channelCosts))
for channelId, costCNY := range channelCosts {
if !validChannelMonitorCostValue(costCNY) || costCNY == 0 {
continue
}
channelName := channelNames[channelId]
if channelName == "" {
channelName = "已删除渠道"
}
costChannels = append(costChannels, channelMonitorCostChannel{
ChannelId: channelId,
ChannelName: channelName,
CostCNY: costCNY,
})
}
sort.Slice(costChannels, func(i int, j int) bool {
if costChannels[i].CostCNY == costChannels[j].CostCNY {
return costChannels[i].ChannelId < costChannels[j].ChannelId
}
return costChannels[i].CostCNY > costChannels[j].CostCNY
})

overview := channelMonitorCostOverview{
Days: days,
GeneratedAt: now,
TotalCostCNY: totalCostCNY,
Coverage: channelMonitorCostCoverage{
IncludedChannelCount: len(includedChannels),
UnresolvedChannelCount: len(unresolvedChannels),
FreeGroupChannelCount: len(freeGroupChannels),
},
Items: items,
Channels: costChannels,
}
if len(items) > 0 {
overview.TodayCostCNY = items[len(items)-1].CostCNY
}
if len(items) > 1 {
overview.YesterdayCostCNY = items[len(items)-2].CostCNY
}
return overview, nil
}

func channelMonitorCostDayStart(timestamp int64) int64 {
return ((timestamp+channelMonitorCostOffset)/channelMonitorCostDaySeconds)*channelMonitorCostDaySeconds - channelMonitorCostOffset
}

func channelMonitorCostDate(dayStart int64) string {
return time.Unix(dayStart+channelMonitorCostOffset, 0).UTC().Format("2006-01-02")
}

func validChannelMonitorCostValue(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
Loading