fix: follow live request results and prioritize usable groups

This commit is contained in:
cneicy committed 2026-09-26 14:49:12 +08:00
1 parent 5152a4080e
commit d09216bbde
12 files changed
+257 -86

No files matched your search

+7 -3
View File
@@ -1,6 +1,6 @@
# 自定义版本说明
当前版本为 v1.0.0-rc.38-custom.43-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。
当前版本为 v1.0.0-rc.38-custom.44-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。
## 主要定制
@@ -11,13 +11,13 @@
- 独立公开 uptime 页,使用 shadcn/ui,支持分组别名、隐藏分组和管理员重置当前分组检测记录。
- 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。
- 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。
- 两页共用分组行布局,正常状态优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。
- 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。
- 鹈鹕检测通过 manxue.ai 访客接口请求公开作品,保存可跳转的作品链接;连接失败时本地生成并标记为不确定。
- 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。
## 从源码构建
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.43-20260926 .
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.44-20260926 .
此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。
@@ -30,3 +30,7 @@
升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。
公开作品链接受 manxue.ai 的保留期限约束;没有公开链接的历史作品继续使用本站预览。
## custom.44 current availability
最近 15 分钟内,以最新完成的真实用户请求结果决定分组状态:成功为正常,上游服务失败为异常;抽检不覆盖这段时间的真实使用结果。客户端取消、参数错误和本地额度不足不改变分组健康状态。没有近期真实请求时使用最近一次独立探活,超过 30 分钟未探活显示未知。禁用分组保持不可用。指纹、鹈鹕和小时请求成功率保留为历史统计,不直接决定当前状态。正常与性能下降分组一起优先排列,同类中 Astra 优先。
+2 -2
View File
@@ -1,3 +1,3 @@
FROM local/new-api:v1.0.0-rc.38-custom.42-20260926
FROM local/new-api:v1.0.0-rc.38-custom.43-20260926
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.43-20260926"
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
+1 -1
View File
@@ -25,7 +25,7 @@ RUN apt-get update \
COPY --from=build /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.43-20260926"
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
+1 -1
View File
@@ -1 +1 @@
v1.0.0-rc.38-custom.43-20260926
v1.0.0-rc.38-custom.44-20260926
+50 -13
View File
@@ -1,28 +1,65 @@
package controller
import (
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/service"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSuccessfulUserRequestRestoresStatus(t *testing.T) {
now := time.Now().Unix()
func TestCurrentGroupStatus(t *testing.T) {
const now int64 = 10000
for _, tc := range []struct {
name string
success, probe int64
disabled bool
want int
name string
success int64
probes []service.ProbeHistoryPoint
disabled bool
want int
source string
at int64
}{
{"fresh user overrides failed probe", now, now - 10, false, 1},
{"newer failed probe wins", now - 10, now, false, 0},
{"expired success", now - 901, now - 1000, false, 0},
{"disabled remains disabled", now, now - 10, true, 0},
{"fresh user overrides older failed probe", now, []service.ProbeHistoryPoint{{At: now - 10, Status: 0}}, false, 1, "requests", now},
{"newer failed probe cannot erase recent user success", now - 10, []service.ProbeHistoryPoint{{At: now, Status: 0}}, false, 1, "requests", now - 10},
{"working primary with failing backup stays normal", now - 60, []service.ProbeHistoryPoint{{At: now, Status: 2}}, false, 1, "requests", now - 60},
{"success remains fresh for full fifteen minutes", now - 900, nil, false, 1, "requests", now - 900},
{"expired user success uses latest partial probe", now - 901, []service.ProbeHistoryPoint{{At: now - 10, Status: 2}}, false, 2, "detection", now - 10},
{"healthy probe overrides historical hourly error", 0, []service.ProbeHistoryPoint{{At: now - 100, Status: 1}}, false, 1, "detection", now - 100},
{"latest probe determines availability", 0, []service.ProbeHistoryPoint{{At: now - 100, Status: 0}, {At: now - 200, Status: 1}}, false, 0, "detection", now - 100},
{"one missed probe remains fresh", 0, []service.ProbeHistoryPoint{{At: now - 1800, Status: 1}}, false, 1, "detection", now - 1800},
{"stale probe is unknown", 0, []service.ProbeHistoryPoint{{At: now - 1801, Status: 0}}, false, -1, "detection", 0},
{"quality failures alone do not imply an outage", 0, nil, false, -1, "detection", 0},
{"disabled remains disabled", now, []service.ProbeHistoryPoint{{At: now, Status: 1}}, true, 0, "disabled", 0},
{"future evidence is ignored", now + 1, []service.ProbeHistoryPoint{{At: now + 1, Status: 1}}, false, -1, "detection", 0},
} {
t.Run(tc.name, func(t *testing.T) {
m := Monitor{Status: 0, ChannelDisabled: tc.disabled}
applyUserSuccess(&m, tc.success, tc.probe, now)
m := Monitor{Status: 0, StatusSource: "requests", StatusCheckedAt: now - 100, ChannelDisabled: tc.disabled, ProbeHistory: tc.probes, Uptime: 0.58, Fingerprint: &service.AstraSamplingSummary{Errors: 25}, Pelican: &service.PelicanSummary{Failed: 33}}
applyCurrentStatus(&m, perfmetrics.UserRequestResult{At: tc.success * 1000, Success: true}, now)
assert.Equal(t, tc.want, m.Status)
assert.Equal(t, tc.source, m.StatusSource)
assert.Equal(t, tc.at, m.StatusCheckedAt)
assert.Equal(t, 25, m.Fingerprint.Errors)
assert.Equal(t, 33, m.Pelican.Failed)
assert.Equal(t, 0.58, m.Uptime)
})
}
}
func TestFailedUserRequestChangesGroupStatus(t *testing.T) {
const now int64 = 10000
for _, tc := range []struct {
name string
at int64
want int
source string
}{
{"user failure overrides a newer healthy probe", now - 10, 0, "requests"},
{"user failure expires after fifteen minutes", now - 901, 1, "detection"},
} {
t.Run(tc.name, func(t *testing.T) {
m := Monitor{Status: 1, ProbeHistory: []service.ProbeHistoryPoint{{At: now, Status: 1}}}
applyCurrentStatus(&m, perfmetrics.UserRequestResult{At: tc.at * 1000, Success: false}, now)
assert.Equal(t, tc.want, m.Status)
assert.Equal(t, tc.source, m.StatusSource)
})
}
}
+26 -14
View File
@@ -126,7 +126,7 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
groupCacheSnapshot.updated = now
groupCacheSnapshot.channels = string(encoded)
}
successes := perfmetrics.LatestUserSuccess(ctx, order)
userResults := perfmetrics.LatestUserResults(ctx, order)
monitors := make([]Monitor, 0, len(order))
for _, group := range order {
monitor := Monitor{ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group]}
@@ -158,16 +158,8 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
monitor.Metrics.Group = monitor.Name
}
monitor.Pelican = detections[group].Pelican
monitor.StatusSource = "requests"
if monitor.ChannelDisabled {
monitor.StatusSource = "disabled"
} else if monitor.Status < 0 {
monitor.Status = detections[group].Status
monitor.StatusSource = "detection"
monitor.StatusCheckedAt = detections[group].At
}
monitor.ProbeHistory = detections[group].ProbeHistory
applyUserSuccess(&monitor, successes[group], detections[group].At, now.Unix())
applyCurrentStatus(&monitor, userResults[group], now.Unix())
monitors = append(monitors, monitor)
}
return []UptimeGroupResult{{CategoryName: "Groups", Monitors: monitors}}, nil
@@ -196,11 +188,31 @@ func requestStatus(rate float64) int {
return 0
}
func applyUserSuccess(monitor *Monitor, success, probe, now int64) {
if !monitor.ChannelDisabled && success > 0 && success >= now-900 && success >= probe {
monitor.Status = 1
func applyCurrentStatus(monitor *Monitor, result perfmetrics.UserRequestResult, now int64) {
monitor.Status, monitor.StatusCheckedAt = -1, 0
monitor.StatusSource = "detection"
if monitor.ChannelDisabled {
monitor.Status = 0
monitor.StatusSource = "disabled"
return
}
// Current availability uses dedicated probes, not quality tests or hourly
// request ratios. Allow one missed 15-minute probe before evidence expires.
for _, probe := range monitor.ProbeHistory {
if probe.At > 0 && probe.At >= now-1800 && probe.At <= now && probe.At >= monitor.StatusCheckedAt {
monitor.Status = probe.Status
monitor.StatusCheckedAt = probe.At
}
}
// The latest real user result is stronger evidence than synthetic checks
// on another member channel, whether it succeeded or failed.
if at := result.At / 1000; at > 0 && at >= now-900 && at <= now {
monitor.Status = 0
if result.Success {
monitor.Status = 1
}
monitor.StatusSource = "requests"
monitor.StatusCheckedAt = success
monitor.StatusCheckedAt = at
}
}
+41 -21
View File
@@ -2,59 +2,79 @@ package perfmetrics
import (
"context"
"github.com/QuantumNous/new-api/common"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
)
var liveSuccess sync.Map
type successTime struct {
sync.Mutex
at int64
// At is milliseconds so a success and failure in the same second stay ordered.
type UserRequestResult struct {
At int64
Success bool
}
func recordUserSuccess(group string, at int64) {
var liveResults sync.Map
type userResultState struct {
sync.Mutex
result UserRequestResult
}
func recordUserResult(group string, result UserRequestResult) {
if group == "" {
return
}
value, _ := liveSuccess.LoadOrStore(group, &successTime{})
v := value.(*successTime)
value, _ := liveResults.LoadOrStore(group, &userResultState{})
v := value.(*userResultState)
v.Lock()
v.at = max(v.at, at)
if result.At >= v.result.At {
v.result = result
}
v.Unlock()
if common.RedisEnabled && common.RDB != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// A delayed writer must not replace a newer successful request.
_ = common.RDB.Eval(ctx, `local old=tonumber(redis.call('GET',KEYS[1]) or '0'); if tonumber(ARGV[1])>old then redis.call('SET',KEYS[1],ARGV[1],'EX',900) end; return 1`, []string{"status:user-success:" + group}, at).Err()
encoded := strconv.FormatInt(result.At, 10) + ":" + strconv.FormatBool(result.Success)
// Delayed writers cannot replace newer evidence from another instance.
_ = common.RDB.Eval(ctx, `local old=redis.call('GET',KEYS[1]); local at=old and tonumber(string.match(old,'^(%d+):')) or 0; if tonumber(ARGV[1])>=at then redis.call('SET',KEYS[1],ARGV[2],'EX',900) end; return 1`, []string{"status:user-result:" + group}, result.At, encoded).Err()
}
}
func LatestUserSuccess(ctx context.Context, groups []string) map[string]int64 {
result := map[string]int64{}
func LatestUserResults(ctx context.Context, groups []string) map[string]UserRequestResult {
result := map[string]UserRequestResult{}
for _, group := range groups {
if value, ok := liveSuccess.Load(group); ok {
v := value.(*successTime)
if value, ok := liveResults.Load(group); ok {
v := value.(*userResultState)
v.Lock()
result[group] = v.at
result[group] = v.result
v.Unlock()
}
}
if common.RedisEnabled && common.RDB != nil && len(groups) > 0 {
keys := make([]string, len(groups))
for i, g := range groups {
keys[i] = "status:user-success:" + g
keys[i] = "status:user-result:" + g
}
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
values, err := common.RDB.MGet(ctx, keys...).Result()
if err == nil {
for i, value := range values {
if s, ok := value.(string); ok {
at, _ := strconv.ParseInt(s, 10, 64)
result[groups[i]] = max(result[groups[i]], at)
s, ok := value.(string)
if !ok {
continue
}
atText, successText, ok := strings.Cut(s, ":")
if !ok {
continue
}
at, atErr := strconv.ParseInt(atText, 10, 64)
success, successErr := strconv.ParseBool(successText)
if atErr == nil && successErr == nil && at >= result[groups[i]].At {
result[groups[i]] = UserRequestResult{At: at, Success: success}
}
}
}
+68 -20
View File
@@ -2,35 +2,83 @@ package perfmetrics
import (
"context"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLiveUserSuccess(t *testing.T) {
t.Cleanup(func() {
for _, name := range []string{"user", "probe", "internal", "failed"} {
liveSuccess.Delete(name)
}
})
func TestLiveUserResults(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
canceled, cancel := context.WithCancel(context.Background())
cancel()
for _, tc := range []struct {
name string
test bool
token int
fail bool
want bool
name string
test bool
token int
ctx context.Context
err *types.NewAPIError
want bool
success bool
}{
{"user", false, 1, false, true}, {"probe", true, 1, false, false}, {"internal", false, 0, false, false}, {"failed", false, 1, true, false},
{"success", false, 1, context.Background(), nil, true, true},
{"upstream 503", false, 1, context.Background(), types.InitOpenAIError("server_error", 503), true, false},
{"probe", true, 1, context.Background(), types.InitOpenAIError("server_error", 503), false, false},
{"internal", false, 0, context.Background(), nil, false, false},
{"canceled", false, 1, canceled, types.InitOpenAIError("server_error", 503), false, false},
{"bad prompt", false, 1, context.Background(), types.InitOpenAIError("context_length_exceeded", 400), false, false},
} {
t.Run(tc.name, func(t *testing.T) {
var apiErr *types.NewAPIError
if tc.fail {
apiErr = types.InitOpenAIError("server_error", 500)
t.Cleanup(func() { liveResults.Delete(tc.name) })
RecordRelayResult(tc.ctx, &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, tc.err)
result := LatestUserResults(context.Background(), []string{tc.name})[tc.name]
assert.Equal(t, tc.want, result.At > 0)
if tc.want {
assert.Equal(t, tc.success, result.Success)
}
RecordRelayResult(context.Background(), &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, apiErr)
assert.Equal(t, tc.want, LatestUserSuccess(context.Background(), []string{tc.name})[tc.name] > 0)
})
}
}
func TestUserResultsAreOrderedAndSurviveRestart(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
oldRedis, oldClient := common.RedisEnabled, common.RDB
common.RedisEnabled, common.RDB = true, client
t.Cleanup(func() {
common.RedisEnabled, common.RDB = oldRedis, oldClient
liveResults.Delete("group")
liveResults.Delete("other")
client.Close()
})
ctx := context.Background()
check := func(want UserRequestResult) {
assert.Equal(t, want, LatestUserResults(ctx, []string{"group"})["group"])
liveResults.Delete("group")
assert.Equal(t, want, LatestUserResults(ctx, []string{"group"})["group"])
}
recordUserResult("other", UserRequestResult{At: 2000, Success: true})
recordUserResult("group", UserRequestResult{At: 2000, Success: true})
check(UserRequestResult{At: 2000, Success: true})
recordUserResult("group", UserRequestResult{At: 2001, Success: false})
check(UserRequestResult{At: 2001, Success: false})
recordUserResult("group", UserRequestResult{At: 2000, Success: true})
check(UserRequestResult{At: 2001, Success: false})
recordUserResult("group", UserRequestResult{At: 2002, Success: true})
recordUserResult("group", UserRequestResult{At: 2001, Success: false})
check(UserRequestResult{At: 2002, Success: true})
assert.True(t, LatestUserResults(ctx, []string{"other"})["other"].Success)
ttl, err := client.TTL(ctx, "status:user-result:group").Result()
require.NoError(t, err)
assert.Equal(t, 15*time.Minute, ttl)
server.FastForward(15 * time.Minute)
assert.Zero(t, LatestUserResults(ctx, []string{"group"})["group"].At)
}
+2 -2
View File
@@ -38,8 +38,8 @@ func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr
return
}
now := time.Now()
if outcome == OutcomeSuccess && !info.IsChannelTest && info.TokenId > 0 {
recordUserSuccess(info.UsingGroup, now.Unix())
if !info.IsChannelTest && info.TokenId > 0 {
recordUserResult(info.UsingGroup, UserRequestResult{At: now.UnixMilli(), Success: outcome == OutcomeSuccess})
}
hasTtft := info.IsStream && info.HasSendResponse()
ttftMs := int64(0)
@@ -194,9 +194,20 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => {
data: [
{
categoryName: 'Groups',
monitors: [
{ name: 'Failed Astra', status: 0, uptime: 0, models: ['gpt-6-astra'] },
{ name: 'Luna only', status: 1, uptime: 1, models: ['gpt-6-luna'] },
monitors: [
{
name: 'Failed Astra',
status: 0,
uptime: 0,
models: ['gpt-6-astra'],
},
{ name: 'Luna only', status: 1, uptime: 1, models: ['gpt-6-luna'] },
{
name: 'Degraded Astra',
status: 2,
uptime: 0.8,
models: ['gpt-6-astra'],
},
{
name: 'Astra group',
status: 1,
@@ -214,12 +225,12 @@ it('keeps non-Astra groups compact and puts Astra groups first', async () => {
screen
.getAllByRole('heading', { level: 2 })
.map((heading) => heading.textContent)
).toEqual(['Astra group', 'Luna only', 'Failed Astra'])
expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(2)
expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(2)
).toEqual(['Degraded Astra', 'Astra group', 'Luna only', 'Failed Astra'])
expect(screen.getAllByText(t('Recent pelican works'))).toHaveLength(3)
expect(screen.getAllByText(t('Model fingerprint'))).toHaveLength(3)
expect(
screen.getAllByRole('region', { name: t('Availability history') })
).toHaveLength(3)
).toHaveLength(4)
})
it('keeps the main-site group status link on the original internal route', () => {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
cacheHitRates,
compareStatusMonitors,
formatCacheHitRate,
getOverallStatus,
getProbeInterval,
@@ -9,6 +10,32 @@ import {
} from '../lib/status'
describe('group status mapping', () => {
it('prioritizes normal and degraded groups together, then Astra', () => {
const rows = [
{ name: 'down-astra', status: 0, uptime: 0, models: ['gpt-6-astra'] },
{ name: 'normal-luna', status: 1, uptime: 1, models: ['gpt-6-luna'] },
{
name: 'partial-astra',
status: 2,
uptime: 0.8,
models: ['gpt-6-astra'],
},
{ name: 'normal-astra', status: 1, uptime: 1, models: ['gpt-6-astra'] },
{ name: 'degraded-luna', status: 3, uptime: 0.8, models: ['gpt-6-luna'] },
{ name: 'unknown-luna', status: -1, uptime: 0, models: ['gpt-6-luna'] },
]
expect(
[...rows].sort(compareStatusMonitors).map((row) => row.name)
).toEqual([
'partial-astra',
'normal-astra',
'normal-luna',
'degraded-luna',
'down-astra',
'unknown-luna',
])
})
it('maps down monitors to error', () => {
expect(statusKind(0)).toBe('error')
})
+14 -2
View File
@@ -6,8 +6,20 @@ export function supportsAstra(monitor: UptimeMonitor): boolean {
return monitor.models?.includes('gpt-6-astra') ?? false
}
export function compareStatusMonitors(a: UptimeMonitor, b: UptimeMonitor): number {
return Number(b.status === 1) - Number(a.status === 1) || Number(supportsAstra(b)) - Number(supportsAstra(a))
export function compareStatusMonitors(
a: UptimeMonitor,
b: UptimeMonitor
): number {
const aUsable =
statusKind(a.status) === 'operational' ||
statusKind(a.status) === 'degraded'
const bUsable =
statusKind(b.status) === 'operational' ||
statusKind(b.status) === 'degraded'
return (
Number(bUsable) - Number(aUsable) ||
Number(supportsAstra(b)) - Number(supportsAstra(a))
)
}
export function statusKind(status: number): GroupStatusKind {