From 17ae3f9d57ecf91a18cfc117198af717f2eebad6 Mon Sep 17 00:00:00 2001 From: InfinityCat <13365204405@163.com> Date: Mon, 14 Sep 2026 00:06:01 +0800 Subject: [PATCH 1/4] fix(accounts): align SenseNova usage with point quotas --- .../internal/service/account_usage_service.go | 141 ++++++++++-------- .../internal/service/sensenova_usage_test.go | 67 +++++---- .../components/account/AccountUsageCell.vue | 37 +++-- .../__tests__/AccountUsageCell.spec.ts | 30 ++-- .../src/i18n/locales/en/admin/accounts.ts | 5 +- .../src/i18n/locales/zh/admin/accounts.ts | 5 +- frontend/src/types/index.ts | 6 +- 7 files changed, 176 insertions(+), 115 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 523738a2ac3f..e9cbbfa3010c 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -116,9 +116,14 @@ const ( ) const ( - senseNovaRateWindow = time.Minute - senseNovaRPMLimit int64 = 60 - senseNovaTPMLimit int64 = 128000 + // SenseNova API key accounts use local successful-usage logs to render the + // documented rolling point windows. The minute window remains separate for + // upstream 429 cooldown handling. + senseNovaRateWindow = time.Minute + senseNovaFiveHourWindow = 5 * time.Hour + senseNovaWeeklyWindow = 7 * 24 * time.Hour + senseNovaFiveHourPointsLimit = int64(60000) + senseNovaWeeklyPointsLimit = int64(600000) ) const ( @@ -167,6 +172,8 @@ type UsageProgress struct { LimitRequests int64 `json:"limit_requests,omitempty"` UsedTokens int64 `json:"used_tokens,omitempty"` LimitTokens int64 `json:"limit_tokens,omitempty"` + UsedPoints int64 `json:"used_points,omitempty"` + LimitPoints int64 `json:"limit_points,omitempty"` } // AntigravityModelQuota Antigravity 单个模型的配额信息 @@ -208,8 +215,8 @@ type UsageInfo struct { GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist) GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM - SenseNovaRPM *UsageProgress `json:"sensenova_rpm,omitempty"` // SenseNova documented RPM window - SenseNovaTPM *UsageProgress `json:"sensenova_tpm,omitempty"` // SenseNova documented TPM window + SenseNovaFiveHour *UsageProgress `json:"sensenova_five_hour,omitempty"` // SenseNova rolling 5-hour point window + SenseNovaSevenDay *UsageProgress `json:"sensenova_seven_day,omitempty"` // SenseNova rolling 7-day point window ChatAnywhereDaily *UsageProgress `json:"chatanywhere_daily,omitempty"` // Local rolling 24h request observation ChatAnywhereWeekly *UsageProgress `json:"chatanywhere_weekly,omitempty"` // Local rolling 7d token observation ChatAnywhereQuotaStatus string `json:"chatanywhere_quota_status,omitempty"` @@ -411,8 +418,9 @@ func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *A return usage, err } - // SenseNova API key accounts expose documented RPM/TPM limits rather than - // an OAuth quota endpoint. The windows are calculated from local usage logs. + // SenseNova API key accounts expose documented rolling point limits rather + // than an OAuth quota endpoint. The windows are calculated from local usage + // logs while local balance deduction remains in the existing billing path. if account.IsSenseNova() { usage, err := s.getSenseNovaUsage(ctx, account) if err == nil { @@ -529,7 +537,7 @@ func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *A // GetUsage 获取账号使用量 // OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟 // Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope) -// SenseNova API Key账号: 根据本地日志计算文档定义的RPM/TPM窗口 +// SenseNova API Key账号: 根据本地日志计算文档定义的滚动积分窗口 // 其他 API Key账号: 不支持usage查询 func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { forceProbe := len(force) > 0 && force[0] @@ -811,28 +819,57 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou } func (s *AccountUsageService) getSenseNovaUsage(ctx context.Context, account *Account) (*UsageInfo, error) { - now := time.Now() - stats := &usagestats.AccountStats{} - if s.usageLogRepo != nil { - windowStart := now.Truncate(senseNovaRateWindow) - if queried, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, windowStart); err == nil { - if queried != nil { - stats = queried - } - } else { - slog.Warn("sensenova_local_usage_query_failed", "account_id", account.ID, "error", err) - } - } + now := time.Now().UTC() + fiveHourStats := querySenseNovaWindowStats( + ctx, + s.usageLogRepo, + account.ID, + now.Add(-senseNovaFiveHourWindow), + "5h", + ) + weeklyStats := querySenseNovaWindowStats( + ctx, + s.usageLogRepo, + account.ID, + now.Add(-senseNovaWeeklyWindow), + "7d", + ) - rpm, tpm := buildSenseNovaUsageProgress(account, stats, now) return &UsageInfo{ - Source: "active", - UpdatedAt: &now, - SenseNovaRPM: rpm, - SenseNovaTPM: tpm, + Source: "local", + UpdatedAt: &now, + SenseNovaFiveHour: buildSenseNovaUsageProgress(account, fiveHourStats, senseNovaFiveHourPointsLimit, now), + SenseNovaSevenDay: buildSenseNovaUsageProgress(account, weeklyStats, senseNovaWeeklyPointsLimit, now), }, nil } +func querySenseNovaWindowStats( + ctx context.Context, + repo UsageLogRepository, + accountID int64, + startTime time.Time, + windowName string, +) *usagestats.AccountStats { + if repo == nil { + return &usagestats.AccountStats{} + } + + stats, err := repo.GetAccountWindowStats(ctx, accountID, startTime) + if err != nil { + slog.Warn( + "sensenova_local_usage_query_failed", + "account_id", accountID, + "window", windowName, + "error", err, + ) + return &usagestats.AccountStats{} + } + if stats == nil { + return &usagestats.AccountStats{} + } + return stats +} + func (s *AccountUsageService) getChatAnywhereUsage(ctx context.Context, account *Account) (*UsageInfo, error) { now := time.Now().UTC() dailyStats := &usagestats.AccountStats{} @@ -926,48 +963,34 @@ func maxInt64(value, floor int64) int64 { return value } -func buildSenseNovaUsageProgress(account *Account, stats *usagestats.AccountStats, now time.Time) (*UsageProgress, *UsageProgress) { +func buildSenseNovaUsageProgress(account *Account, stats *usagestats.AccountStats, pointsLimit int64, now time.Time) *UsageProgress { if stats == nil { stats = &usagestats.AccountStats{} } - resetAt := now.Truncate(senseNovaRateWindow).Add(senseNovaRateWindow) - rateLimited := false - if account != nil && account.RateLimitResetAt != nil { - if now.Before(*account.RateLimitResetAt) { - rateLimited = true - } - if account.RateLimitResetAt.After(resetAt) { - resetAt = *account.RateLimitResetAt - } + usedPoints := maxInt64(stats.Tokens, 0) + progress := &UsageProgress{ + Utilization: float64(usedPoints) / float64(pointsLimit) * 100, + WindowStats: windowStatsFromAccountStats(stats), + UsedPoints: usedPoints, + LimitPoints: pointsLimit, } - remainingSeconds := int(resetAt.Sub(now).Seconds()) - if remainingSeconds < 0 { - remainingSeconds = 0 - } - resetCopy := resetAt - windowStats := windowStatsFromAccountStats(stats) - rpmUtilization := float64(stats.Requests) / float64(senseNovaRPMLimit) * 100 - tpmUtilization := float64(stats.Tokens) / float64(senseNovaTPMLimit) * 100 - if rateLimited { - rpmUtilization = 100 - tpmUtilization = 100 + // A persisted upstream 429 cooldown is separate from the rolling quota. + // Surface it as a temporary full bar without inventing a reset timestamp for + // a rolling window, whose next reset depends on the oldest request. + if account != nil && account.RateLimitResetAt != nil && now.Before(*account.RateLimitResetAt) { + resetCopy := *account.RateLimitResetAt + remainingSeconds := int(resetCopy.Sub(now).Seconds()) + if remainingSeconds < 0 { + remainingSeconds = 0 + } + progress.Utilization = 100 + progress.ResetsAt = &resetCopy + progress.RemainingSeconds = remainingSeconds } - return &UsageProgress{ - Utilization: rpmUtilization, - ResetsAt: &resetCopy, - RemainingSeconds: remainingSeconds, - WindowStats: windowStats, - UsedRequests: stats.Requests, - LimitRequests: senseNovaRPMLimit, - }, &UsageProgress{ - Utilization: tpmUtilization, - ResetsAt: &resetCopy, - RemainingSeconds: remainingSeconds, - WindowStats: windowStats, - } + return progress } func shouldRefreshOpenAICodexSnapshot(account *Account, usage *UsageInfo, now time.Time) bool { diff --git a/backend/internal/service/sensenova_usage_test.go b/backend/internal/service/sensenova_usage_test.go index 8640d4358b48..44952894815d 100644 --- a/backend/internal/service/sensenova_usage_test.go +++ b/backend/internal/service/sensenova_usage_test.go @@ -11,13 +11,16 @@ import ( type senseNovaUsageRepo struct { UsageLogRepository - stats *usagestats.AccountStats - startTime time.Time + stats []*usagestats.AccountStats + startTimes []time.Time } func (r *senseNovaUsageRepo) GetAccountWindowStats(_ context.Context, _ int64, startTime time.Time) (*usagestats.AccountStats, error) { - r.startTime = startTime - return r.stats, nil + r.startTimes = append(r.startTimes, startTime) + if index := len(r.startTimes) - 1; index < len(r.stats) { + return r.stats[index], nil + } + return &usagestats.AccountStats{}, nil } type senseNovaRateLimitRepo struct { @@ -32,8 +35,11 @@ func (r *senseNovaRateLimitRepo) SetRateLimited(_ context.Context, _ int64, rese return nil } -func TestGetUsageForAccount_SenseNovaUsesLocalRPMAndTPMWindows(t *testing.T) { - repo := &senseNovaUsageRepo{stats: &usagestats.AccountStats{Requests: 30, Tokens: 64000}} +func TestGetUsageForAccount_SenseNovaUsesRollingPointWindows(t *testing.T) { + repo := &senseNovaUsageRepo{stats: []*usagestats.AccountStats{ + {Requests: 30, Tokens: 30000}, + {Requests: 30, Tokens: 300000}, + }} svc := &AccountUsageService{usageLogRepo: repo} account := &Account{ID: 42, Platform: PlatformSenseNova, Type: AccountTypeAPIKey} @@ -41,41 +47,48 @@ func TestGetUsageForAccount_SenseNovaUsesLocalRPMAndTPMWindows(t *testing.T) { if err != nil { t.Fatalf("getUsageForAccount() error = %v", err) } - if usage == nil || usage.SenseNovaRPM == nil || usage.SenseNovaTPM == nil { - t.Fatalf("expected SenseNova RPM/TPM usage, got %#v", usage) + if usage == nil || usage.SenseNovaFiveHour == nil || usage.SenseNovaSevenDay == nil { + t.Fatalf("expected SenseNova rolling point usage, got %#v", usage) + } + if usage.SenseNovaFiveHour.Utilization != 50 { + t.Fatalf("5-hour utilization = %v, want 50", usage.SenseNovaFiveHour.Utilization) + } + if usage.SenseNovaSevenDay.Utilization != 50 { + t.Fatalf("7-day utilization = %v, want 50", usage.SenseNovaSevenDay.Utilization) } - if usage.SenseNovaRPM.Utilization != 50 { - t.Fatalf("RPM utilization = %v, want 50", usage.SenseNovaRPM.Utilization) + if usage.SenseNovaFiveHour.LimitPoints != senseNovaFiveHourPointsLimit { + t.Fatalf("5-hour points limit = %d, want %d", usage.SenseNovaFiveHour.LimitPoints, senseNovaFiveHourPointsLimit) } - if usage.SenseNovaTPM.Utilization != 50 { - t.Fatalf("TPM utilization = %v, want 50", usage.SenseNovaTPM.Utilization) + if usage.SenseNovaSevenDay.LimitPoints != senseNovaWeeklyPointsLimit { + t.Fatalf("7-day points limit = %d, want %d", usage.SenseNovaSevenDay.LimitPoints, senseNovaWeeklyPointsLimit) } - if usage.SenseNovaRPM.LimitRequests != senseNovaRPMLimit { - t.Fatalf("RPM limit = %d, want %d", usage.SenseNovaRPM.LimitRequests, senseNovaRPMLimit) + if usage.SenseNovaFiveHour.UsedPoints != 30000 || usage.SenseNovaSevenDay.UsedPoints != 300000 { + t.Fatalf("unexpected point usage: (%d, %d)", usage.SenseNovaFiveHour.UsedPoints, usage.SenseNovaSevenDay.UsedPoints) } - if usage.SenseNovaRPM.WindowStats == nil || usage.SenseNovaRPM.WindowStats.Tokens != 64000 { - t.Fatalf("unexpected local window stats: %#v", usage.SenseNovaRPM.WindowStats) + if len(repo.startTimes) != 2 { + t.Fatalf("window queries = %d, want 2", len(repo.startTimes)) } - if repo.startTime.IsZero() { - t.Fatal("expected local minute window query") + queryNow := time.Now().UTC() + if got := repo.startTimes[0]; got.Before(queryNow.Add(-senseNovaFiveHourWindow - 2*time.Second)) || got.After(queryNow.Add(-senseNovaFiveHourWindow+2*time.Second)) { + t.Fatalf("5-hour window start = %v, want roughly now - 5h", got) + } + if got := repo.startTimes[1]; got.Before(queryNow.Add(-senseNovaWeeklyWindow - 2*time.Second)) || got.After(queryNow.Add(-senseNovaWeeklyWindow+2*time.Second)) { + t.Fatalf("7-day window start = %v, want roughly now - 7d", got) } } -func TestBuildSenseNovaUsageProgress_RateLimitFillsBothWindows(t *testing.T) { +func TestBuildSenseNovaUsageProgress_RateLimitFillsWindow(t *testing.T) { now := time.Date(2026, 9, 12, 12, 34, 45, 0, time.UTC) rateLimitReset := now.Add(90 * time.Second) account := &Account{RateLimitResetAt: &rateLimitReset} stats := &usagestats.AccountStats{Requests: 1, Tokens: 2} - rpm, tpm := buildSenseNovaUsageProgress(account, stats, now) - if rpm.Utilization != 100 || tpm.Utilization != 100 { - t.Fatalf("rate-limited utilization = (%v, %v), want (100, 100)", rpm.Utilization, tpm.Utilization) - } - if rpm.ResetsAt == nil || !rpm.ResetsAt.Equal(rateLimitReset) { - t.Fatalf("RPM reset = %v, want %v", rpm.ResetsAt, rateLimitReset) + progress := buildSenseNovaUsageProgress(account, stats, senseNovaFiveHourPointsLimit, now) + if progress.Utilization != 100 { + t.Fatalf("rate-limited utilization = %v, want 100", progress.Utilization) } - if tpm.ResetsAt == nil || !tpm.ResetsAt.Equal(rateLimitReset) { - t.Fatalf("TPM reset = %v, want %v", tpm.ResetsAt, rateLimitReset) + if progress.ResetsAt == nil || !progress.ResetsAt.Equal(rateLimitReset) { + t.Fatalf("rolling point reset = %v, want %v", progress.ResetsAt, rateLimitReset) } } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 69f60be92120..d3c1e64b8ae1 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -192,7 +192,7 @@ - +