Skip to content

Commit d9b4a8c

Browse files
committed
fix: align account rate-limit testing stats
1 parent 4a48799 commit d9b4a8c

4 files changed

Lines changed: 157 additions & 30 deletions

File tree

admin/test_connection.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ func (h *Handler) TestConnection(c *gin.Context) {
7474
// 发送 test_start
7575
sendTestEvent(c, testEvent{Type: "test_start", Model: testModel})
7676

77+
if resetAt, ok := activeLocalRateLimitReset(account); ok && !forceConnectionTest(c) {
78+
sendTestEvent(c, testEvent{Type: "error", Error: formatLocalRateLimitTestError(resetAt)})
79+
return
80+
}
81+
7782
// 构建最小测试请求体(参考 sub2api createOpenAITestPayload)
7883
payload := buildTestPayload(testModel)
7984

@@ -100,6 +105,10 @@ func (h *Handler) TestConnection(c *gin.Context) {
100105
}
101106

102107
usageState := proxy.SyncCodexUsageState(h.store, account, resp)
108+
if msg, limited := formatUsageLimitedTestError(usageState); limited {
109+
sendTestEvent(c, testEvent{Type: "error", Error: msg})
110+
return
111+
}
103112

104113
// 解析 SSE 流
105114
hasContent := false
@@ -220,6 +229,58 @@ func buildTestPayload(model string) []byte {
220229
return payload
221230
}
222231

232+
func forceConnectionTest(c *gin.Context) bool {
233+
if c == nil {
234+
return false
235+
}
236+
switch strings.ToLower(strings.TrimSpace(c.Query("force"))) {
237+
case "1", "true", "yes":
238+
return true
239+
default:
240+
return false
241+
}
242+
}
243+
244+
func activeLocalRateLimitReset(account *auth.Account) (time.Time, bool) {
245+
if account == nil {
246+
return time.Time{}, false
247+
}
248+
now := time.Now()
249+
if account.IsPremium5hRateLimited() {
250+
_, resetAt, ok := account.GetUsageSnapshot5h()
251+
if ok && resetAt.After(now) {
252+
return resetAt, true
253+
}
254+
}
255+
reason, until := account.GetCooldownSnapshot()
256+
if reason == "rate_limited" && until.After(now) {
257+
return until, true
258+
}
259+
return time.Time{}, false
260+
}
261+
262+
func formatLocalRateLimitTestError(resetAt time.Time) string {
263+
remaining := time.Until(resetAt).Round(time.Second)
264+
if remaining < 0 {
265+
remaining = 0
266+
}
267+
return fmt.Sprintf("账号当前处于本地限流状态,预计 %s 后恢复;本次未发送上游探针。需要强制探针时可添加 force=1。", remaining)
268+
}
269+
270+
func formatUsageLimitedTestError(state proxy.CodexUsageSyncResult) (string, bool) {
271+
if state.Premium5hRateLimited {
272+
remaining := time.Until(state.Reset5hAt).Round(time.Second)
273+
if remaining < 0 {
274+
remaining = 0
275+
}
276+
return fmt.Sprintf("上游探针返回 200,但 Codex 5h 用量头已达 %.0f%%,账号已保持限流状态,预计 %s 后恢复。", state.UsagePct5h, remaining), true
277+
}
278+
if state.HasUsage7d && state.UsagePct7d >= 100 {
279+
return fmt.Sprintf("上游探针返回 200,但 Codex 7d 用量头已达 %.0f%%,账号已保持用量耗尽状态。", state.UsagePct7d), true
280+
}
281+
return "", false
282+
}
283+
223284
// sendTestEvent 发送 SSE 事件
224285
func sendTestEvent(c *gin.Context, event testEvent) {
225286
data, err := json.Marshal(event)
@@ -462,6 +523,11 @@ func (h *Handler) BatchTest(c *gin.Context) {
462523
sem <- struct{}{}
463524
defer func() { <-sem }()
464525

526+
if _, ok := activeLocalRateLimitReset(acc); ok {
527+
atomic.AddInt64(&rateLimitCount, 1)
528+
return
529+
}
530+
465531
resp, err := proxy.ExecuteRequest(context.Background(), acc, payload, "", h.store.ResolveProxyForAccount(acc), "", nil, nil)
466532
if err != nil {
467533
h.store.MarkError(acc, "批量测试请求失败: "+err.Error())
@@ -474,10 +540,12 @@ func (h *Handler) BatchTest(c *gin.Context) {
474540
switch resp.StatusCode {
475541
case http.StatusOK:
476542
usageState := proxy.SyncCodexUsageState(h.store, acc, resp)
477-
// 测试成功即重置冷却状态,用量限制由调度器自行判断
478-
if !usageState.Premium5hRateLimited && (!usageState.HasUsage7d || usageState.UsagePct7d < 100) {
479-
h.store.ClearCooldown(acc)
543+
if _, limited := formatUsageLimitedTestError(usageState); limited {
544+
atomic.AddInt64(&rateLimitCount, 1)
545+
return
480546
}
547+
// 测试成功即重置冷却状态,用量限制由调度器自行判断
548+
h.store.ClearCooldown(acc)
481549
atomic.AddInt64(&successCount, 1)
482550
case http.StatusUnauthorized:
483551
proxy.SyncCodexUsageState(h.store, acc, resp)

admin/test_connection_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package admin
33
import (
44
"strings"
55
"testing"
6+
"time"
67

8+
"github.com/codex2api/auth"
9+
"github.com/codex2api/proxy"
710
"github.com/tidwall/gjson"
811
)
912

@@ -29,6 +32,41 @@ func TestBuildTestPayloadUsesSelectedModel(t *testing.T) {
2932
}
3033
}
3134

35+
func TestActiveLocalRateLimitResetDetectsPremium5h(t *testing.T) {
36+
resetAt := time.Now().Add(30 * time.Minute)
37+
acc := &auth.Account{
38+
PlanType: "plus",
39+
UsagePercent5h: 100,
40+
UsagePercent5hValid: true,
41+
Reset5hAt: resetAt,
42+
}
43+
44+
got, ok := activeLocalRateLimitReset(acc)
45+
if !ok {
46+
t.Fatal("activeLocalRateLimitReset() ok = false, want true")
47+
}
48+
if !got.Equal(resetAt) {
49+
t.Fatalf("resetAt = %v, want %v", got, resetAt)
50+
}
51+
}
52+
53+
func TestFormatUsageLimitedTestErrorReportsSuccessfulProbeAsLimited(t *testing.T) {
54+
msg, limited := formatUsageLimitedTestError(proxy.CodexUsageSyncResult{
55+
Premium5hRateLimited: true,
56+
UsagePct5h: 100,
57+
Reset5hAt: time.Now().Add(time.Hour),
58+
})
59+
60+
if !limited {
61+
t.Fatal("limited = false, want true")
62+
}
63+
for _, want := range []string{"返回 200", "5h 用量头", "限流状态"} {
64+
if !strings.Contains(msg, want) {
65+
t.Fatalf("message %q does not contain %q", msg, want)
66+
}
67+
}
68+
}
69+
3270
func TestExtractCompletedOutputText(t *testing.T) {
3371
event := []byte(`{
3472
"type":"response.completed",

frontend/src/components/StatusBadge.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
33

44
interface StatusBadgeProps {
55
status?: string | null
6+
detail?: string | null
67
}
78

89
const statusConfig: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; dotColor: string }> = {
@@ -17,7 +18,7 @@ const statusConfig: Record<string, { variant: 'default' | 'secondary' | 'destruc
1718
paused: { variant: 'outline', dotColor: 'bg-blue-500' },
1819
}
1920

20-
export default function StatusBadge({ status }: StatusBadgeProps) {
21+
export default function StatusBadge({ status, detail }: StatusBadgeProps) {
2122
const { t } = useTranslation()
2223
const key = status ?? 'unknown'
2324
const config = statusConfig[key] ?? { variant: 'outline' as const, dotColor: 'bg-gray-400' }
@@ -26,6 +27,12 @@ export default function StatusBadge({ status }: StatusBadgeProps) {
2627
<Badge variant={config.variant} className="gap-1.5 text-[13px]">
2728
<span className={`size-1.5 rounded-full ${config.dotColor}`} />
2829
{t(`status.${key}`, { defaultValue: t('status.unknown', { defaultValue: key }) })}
30+
{detail && (
31+
<>
32+
<span className="h-3 w-px bg-current/20" />
33+
<span className="text-[11px] font-bold leading-none">{detail}</span>
34+
</>
35+
)}
2936
</Badge>
3037
)
3138
}

frontend/src/pages/Accounts.tsx

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,8 @@ export default function Accounts() {
160160

161161
const totalAccounts = accounts.length
162162
const normalAccounts = accounts.filter((account) => account.status === 'active' || account.status === 'ready').length
163-
const rateLimitedAccountRows = accounts.filter(isRateLimitedAccount)
164-
const rateLimitedWindowStats = getRateLimitedWindowStats(rateLimitedAccountRows)
165-
const rateLimitedAccounts = rateLimitedAccountRows.length
163+
const rateLimitedWindowStats = getRateLimitedWindowStats(accounts)
164+
const rateLimitedAccounts = rateLimitedWindowStats.total
166165
const rateLimited5hAccounts = rateLimitedWindowStats.fiveHour
167166
const rateLimited7dAccounts = rateLimitedWindowStats.sevenDay
168167
const bannedAccounts = accounts.filter((account) => account.status === 'unauthorized').length
@@ -1379,7 +1378,7 @@ export default function Accounts() {
13791378
<TableCell>
13801379
<div className="space-y-1.5">
13811380
<div className="flex min-h-6 items-center gap-2 whitespace-nowrap">
1382-
<StatusBadge status={account.status} />
1381+
<StatusBadge status={account.status} detail={getAccountRateLimitWindow(account) ?? undefined} />
13831382
<AccountStatusCountdown account={account} />
13841383
</div>
13851384
{account.status === 'error' && account.error_message && (
@@ -2373,59 +2372,74 @@ function isUsageWindowExhausted(value?: number | null): boolean {
23732372
return typeof value === 'number' && Number.isFinite(value) && value >= 100
23742373
}
23752374

2375+
function isActiveUsageWindowExhausted(value?: number | null, resetAt?: string): boolean {
2376+
return isUsageWindowExhausted(value) && (!resetAt || isFutureTime(resetAt))
2377+
}
2378+
23762379
function isPremiumUsagePlan(planType?: string): boolean {
23772380
return ['plus', 'pro', 'team', 'teamplus'].includes(normalizePlanType(planType))
23782381
}
23792382

2383+
type RateLimitWindow = '5h' | '7d'
2384+
23802385
function isRateLimitedAccount(account: AccountRow): boolean {
2386+
return getAccountRateLimitWindow(account) !== null
2387+
}
2388+
2389+
function getAccountRateLimitWindow(account: AccountRow): RateLimitWindow | null {
23812390
const status = (account.status || '').toLowerCase()
23822391
const reason = (account.cooldown_reason || '').toLowerCase()
2383-
2384-
return status === 'rate_limited' ||
2392+
const explicitlyRateLimited = status === 'rate_limited' ||
23852393
status === 'usage_exhausted' ||
23862394
status === 'rate_limited_5h' ||
23872395
status === 'rate_limited_7d' ||
2396+
reason === 'rate_limited' ||
23882397
reason === 'rate_limited_5h' ||
23892398
reason === 'rate_limited_7d'
2390-
}
2391-
2392-
function getAccountRateLimitWindow(account: AccountRow): '5h' | '7d' {
2393-
const status = (account.status || '').toLowerCase()
2394-
const reason = (account.cooldown_reason || '').toLowerCase()
2399+
const has7dLimit = isActiveUsageWindowExhausted(account.usage_percent_7d, account.reset_7d_at)
2400+
const has5hLimit = isPremiumUsagePlan(account.plan_type) &&
2401+
isActiveUsageWindowExhausted(account.usage_percent_5h, account.reset_5h_at)
23952402

2403+
// Prefer the longer 7d window when both windows are exhausted so each account
2404+
// belongs to exactly one bucket and 5h + 7d stays equal to total limited.
23962405
if (
23972406
status === 'usage_exhausted' ||
23982407
status === 'rate_limited_7d' ||
23992408
reason === 'rate_limited_7d' ||
2400-
(isUsageWindowExhausted(account.usage_percent_7d) && (!account.reset_7d_at || isFutureTime(account.reset_7d_at)))
2409+
has7dLimit
24012410
) {
24022411
return '7d'
24032412
}
24042413

24052414
if (
24062415
status === 'rate_limited_5h' ||
24072416
reason === 'rate_limited_5h' ||
2408-
(
2409-
isPremiumUsagePlan(account.plan_type) &&
2410-
isUsageWindowExhausted(account.usage_percent_5h) &&
2411-
(!account.reset_5h_at || isFutureTime(account.reset_5h_at))
2412-
)
2417+
has5hLimit
24132418
) {
24142419
return '5h'
24152420
}
24162421

2417-
return '5h'
2422+
return explicitlyRateLimited ? '5h' : null
24182423
}
24192424

2420-
function getRateLimitedWindowStats(accounts: AccountRow[]): { fiveHour: number; sevenDay: number } {
2421-
return accounts.reduce((stats, account) => {
2422-
if (getAccountRateLimitWindow(account) === '7d') {
2425+
function getRateLimitedWindowStats(accounts: AccountRow[]): { total: number; fiveHour: number; sevenDay: number } {
2426+
const stats = accounts.reduce((stats, account) => {
2427+
const window = getAccountRateLimitWindow(account)
2428+
if (!window) {
2429+
return stats
2430+
}
2431+
if (window === '7d') {
24232432
stats.sevenDay += 1
24242433
} else {
24252434
stats.fiveHour += 1
24262435
}
24272436
return stats
24282437
}, { fiveHour: 0, sevenDay: 0 })
2438+
2439+
return {
2440+
...stats,
2441+
total: stats.fiveHour + stats.sevenDay,
2442+
}
24292443
}
24302444

24312445
function isSubscriptionPlan(planType?: string): boolean {
@@ -2645,12 +2659,12 @@ function CompactStat({
26452659
{chipLabel ?? label}
26462660
</div>
26472661
{details && details.length > 0 && (
2648-
<div className="flex flex-col items-end gap-0.5 text-[11px] font-semibold leading-4 text-muted-foreground">
2662+
<div className="flex w-[4.75rem] flex-col gap-0.5 text-[11px] font-semibold leading-4 text-muted-foreground">
26492663
{details.map((item) => (
2650-
<div key={item.label} className="tabular-nums">
2651-
<span>{item.label}</span>
2652-
<span className="mx-0.5"></span>
2653-
<span className="text-foreground">{item.value}</span>
2664+
<div key={item.label} className="grid grid-cols-[2ch_auto_1fr] items-center gap-x-1 tabular-nums">
2665+
<span className="justify-self-start">{item.label}</span>
2666+
<span className="justify-self-center"></span>
2667+
<span className="justify-self-end text-foreground">{item.value}</span>
26542668
</div>
26552669
))}
26562670
</div>

0 commit comments

Comments
 (0)